Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion internal/documentation/docs/pages/Overview.md
Original file line number Diff line number Diff line change
Expand Up @@ -119,7 +119,7 @@ The UI5 CLI contains a web server to serve the project via HTTPS protocol.
ui5 serve --https
```

This requires an SSL certificate. You are guided through the automatic generation process. Also see the [UI5 Server documentation](./Server.md#ssl-certificates)
This requires an SSL certificate. Generate one up front with `ui5 certificate generate`. Also see the [UI5 Server documentation](./Server.md#ssl-certificates)

## Integration in Other Tools
One of the key features of the UI5 CLI is its modularization. Single parts of UI5 CLI can easily be integrated in other `Node.js`-based tools and frameworks like [Grunt](https://gruntjs.com/) or [Gulp](https://gulpjs.com/).
Expand Down
10 changes: 8 additions & 2 deletions internal/documentation/docs/pages/Server.md
Original file line number Diff line number Diff line change
Expand Up @@ -228,9 +228,15 @@ A project graph can be served only once. Do not call both `serveMiddleware` and
:::

## SSL Certificates
When starting the UI5 Server in HTTPS mode, for example by using UI5 CLI parameter `--https`, you will be prompted for the automatic generation of a local SSL certificate if necessary.
Starting the UI5 Server in HTTPS mode, for example by using the UI5 CLI parameter `--https`, requires an SSL certificate. Generate and install a self-signed certificate with:

Follow the given instructions and enter your password to install the generated certificate as trusted. You can find the generated certificate and corresponding private key under `.ui5/server` in your user's home directory.
```sh
ui5 certificate generate
```

Follow the given instructions and enter your password (or confirm the dialog on Windows) to install the generated certificate as trusted. By default the certificate and its private key are written to the UI5 data directory (resolved from `UI5_DATA_DIR`, your UI5 config, or `~/.ui5` by default). Use `--force` to overwrite an existing certificate, or `--key`/`--cert` to write to custom paths.

`ui5 serve --https` picks up the certificate from the same default location. If no certificate is found, the server fails with guidance to run `ui5 certificate generate` or to provide existing files via `--key` and `--cert`.

::: tip
If Chrome unintentionally redirects an HTTP-URL to HTTPS, you need to delete the HSTS mapping in [chrome://net-internals/#hsts](chrome://net-internals/#hsts) by entering the domain name (e.g. localhost) and pressing "delete".
Expand Down
2 changes: 1 addition & 1 deletion internal/documentation/docs/pages/Troubleshooting.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ UI5 CLI stores several kinds of data under your user's home directory in `~/.ui5
| ---- | ---- | ---- |
| `~/.ui5/framework/` | Downloaded UI5 framework dependencies (one copy per version) | Yes — re-downloaded on next invocation |
| `~/.ui5/buildCache/` | Build cache used by `ui5 build` and `ui5 serve` (see [Build Cache Control](./Builder.md#build-cache-control)) | Yes — rebuilt on next `ui5 build` / `ui5 serve` |
| `~/.ui5/server/` | Locally generated SSL certificate and private key for HTTPS mode | Yes — regenerated on next HTTPS server start; the new certificate must be re-trusted |
| `~/.ui5/server/` | Locally generated SSL certificate and private key for HTTPS mode | Yes — recreate it with `ui5 certificate generate`; the new certificate must be re-trusted |

#### Resolution

Expand Down
14 changes: 13 additions & 1 deletion internal/documentation/docs/updates/migrate-v5.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@ Or update your global install via: `npm i --global @ui5/cli@next`

- **@ui5/cli: `ui5 serve` renders a status banner in interactive terminals**

- **@ui5/cli: New `ui5 certificate generate` command; `ui5 serve --https` no longer generates a certificate on the fly**

- **@ui5/cli: Option `--experimental-css-variables` has been removed**

- **@ui5/server: The internal Express server has been upgraded from Express 4 to Express 5**
Expand Down Expand Up @@ -383,10 +385,20 @@ The `--h2` option for the `ui5 serve` command has therefore been removed. Use th
ui5 serve --https
```

Like the previous `--h2` option, `--https` requires an SSL certificate and guides you through the automatic generation process on first use. See the [UI5 Server documentation](../pages/Server.md#ssl-certificates) for details.
Like the previous `--h2` option, `--https` requires an SSL certificate. See [SSL Certificate Generation](#ssl-certificate-generation) below to learn how to generate a certificate or provide an existing one.

If you need HTTP/2 for local development, manually set up a reverse proxy, for example, [nginx](https://nginx.org/), in front of the UI5 Server to handle the HTTP/2 connection with the browser.

## SSL Certificate Generation

In previous versions, UI5 CLI generated and installed a self-signed SSL certificate proactively. In UI5 CLI v5, certificate generation is an explicit step:

```sh
ui5 certificate generate
```

For certificate locations, custom paths, and server behavior when no certificate is found, see [SSL Certificates](../pages/Server#ssl-certificates).

## `sap-ui-version.json`

When you run `ui5 build`, the standard task [`generateVersionInfo`](../api/module-@ui5_builder_tasks_generateVersionInfo) now runs by default. This task generates an `sap-ui-version.json` file in the `resources/` directory. The task runs for all build types (default, jsdoc, and self-contained) in projects of type `application`. For other project types (such as `library`), the behavior remains unchanged: [`generateVersionInfo`](../api/module-@ui5_builder_tasks_generateVersionInfo) does not run.
Expand Down
3 changes: 1 addition & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

104 changes: 104 additions & 0 deletions packages/cli/lib/cli/commands/certificate.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
import chalk from "chalk";
import process from "node:process";
import baseMiddleware from "../middlewares/base.js";
import {getUi5DataDirOrDefault, resolveServerCertificatePaths, formatPath} from "../../dataDir.js";
import {exists} from "../../utils/fsHelper.js";

const certificateCommand = {
command: "certificate",
describe: "Manage the UI5 CLI server certificate",
middlewares: [baseMiddleware],
};

certificateCommand.builder = function(cli) {
return cli
.demandCommand(1, "Command required. Available command is 'generate'")
.command("generate", "Generate a self-signed server certificate and install it into the trust store", {
handler: handleGenerate,
builder: function(yargs) {
return yargs
.option("key", {
describe: "Path the private key is written to",
defaultDescription: "~/.ui5/server/server.key",
type: "string"
})
.option("cert", {
describe: "Path the certificate is written to",
defaultDescription: "~/.ui5/server/server.crt",
type: "string"
})
.option("force", {
alias: "f",
describe: "Generate a new certificate even if one already exists at the target path",
default: false,
type: "boolean"
})
.example("$0 certificate generate",
"Generate a server certificate in the default UI5 data directory")
.example("$0 certificate generate --force",
"Regenerate the server certificate, overwriting an existing one")
.example("UI5_DATA_DIR=/custom/path $0 certificate generate",
"Generate a server certificate in a non-default UI5 data directory");
},
middlewares: [baseMiddleware],
});
};

async function handleGenerate(argv) {
const ui5DataDir = await getUi5DataDirOrDefault({cwd: process.cwd()});
const {keyPath, certPath} = resolveServerCertificatePaths(ui5DataDir, {
keyPath: argv.key,
certPath: argv.cert,
});

if (!argv.force) {
let keyExists;
let certExists;
try {
[keyExists, certExists] = await Promise.all([exists(keyPath), exists(certPath)]);
} catch (err) {
throw new Error(
`Failed to check for an existing server certificate at ${formatPath(keyPath)} ` +
`and ${formatPath(certPath)}: ${err.message}`, {cause: err});
}
// Only a complete pair counts as "already existing". A partial state (just the key or just the
// certificate) is a broken pair that the user cannot otherwise repair without --force, so fall
// through to regeneration, which overwrites any leftover file.
if (keyExists && certExists) {
process.stderr.write(
`A server certificate already exists at the target location:\n` +
` Private key: ${chalk.bold(formatPath(keyPath))}\n` +
` Certificate: ${chalk.bold(formatPath(certPath))}\n\n` +
`Use ${chalk.bold("--force")} to generate a new certificate and overwrite the existing one.\n`
);
return;
}
}

// Inform the user before triggering the trust-store installation, which requires elevated
// privileges and therefore prompts for the root password (or shows a confirmation dialog on Windows).
if (process.platform === "win32") {
process.stderr.write("Please press allow in the opened dialog to confirm importing the newly created " +

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
process.stderr.write("Please press allow in the opened dialog to confirm importing the newly created " +
process.stderr.write("Press 'Allow' in the opened dialog to confirm importing the newly created " +

"SSL certificate into the operating system and browsers.\n");
} else {
process.stderr.write("Please enter your root password to allow importing the newly created " +

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
process.stderr.write("Please enter your root password to allow importing the newly created " +
process.stderr.write("Enter your root password to import the newly created " +

"SSL certificate into the operating system and browsers.\n");
}

const {generateSslCertificate} = await import("@ui5/server/internal/sslUtil");
await generateSslCertificate(keyPath, certPath);

process.stderr.write(
`\nServer certificate written:\n` +
` Private key: ${chalk.bold(formatPath(keyPath))}\n` +
` Certificate: ${chalk.bold(formatPath(certPath))}\n`
);

// devcert-sanscache leaves handles open that keep the event loop alive: it resumes stdin to wait
// for the user to confirm the browser import without pausing it again, and its Firefox flow starts
// an HTTP server that is never closed. The latter runs unconditionally on Windows, so the process
// would otherwise hang here on every run. All work is done at this point, so exit explicitly.
process.exit(0);
}

export default certificateCommand;
40 changes: 32 additions & 8 deletions packages/cli/lib/cli/commands/serve.js
Original file line number Diff line number Diff line change
@@ -1,8 +1,7 @@
import path from "node:path";
import os from "node:os";
import process from "node:process";
import baseMiddleware from "../middlewares/base.js";
import {applyProjectConfigOptions, applyWorkspaceOptions, applyBuildOptions, dedupeArray} from "../options.js";
import {getUi5DataDirOrDefault, resolveServerCertificatePaths, formatPath} from "../../dataDir.js";
import {getLogger} from "@ui5/logger";
const log = getLogger("cli:commands:serve");

Expand Down Expand Up @@ -54,12 +53,12 @@ serve.builder = function(cli) {
})
.option("key", {
describe: "Path to the private key",
default: path.join(os.homedir(), ".ui5", "server", "server.key"),
defaultDescription: "~/.ui5/server/server.key",
type: "string"
})
.option("cert", {
describe: "Path to the certificate",
default: path.join(os.homedir(), ".ui5", "server", "server.crt"),
defaultDescription: "~/.ui5/server/server.crt",
type: "string"
})
.option("sap-csp-policies", {
Expand Down Expand Up @@ -231,10 +230,35 @@ serve.handler = async function(argv) {
};

if (serverConfig.https) {
const {getSslCertificate} = await import("@ui5/server/internal/sslUtil");
const {key, cert} = await getSslCertificate(serverConfig.key, serverConfig.cert);
serverConfig.key = key;
serverConfig.cert = cert;
// A default certificate path is only needed for HTTPS, so the UI5 data directory is
// resolved once here rather than for every serve invocation.
const ui5DataDir = await getUi5DataDirOrDefault({cwd: process.cwd()});
const {keyPath, certPath} = resolveServerCertificatePaths(ui5DataDir, {
keyPath: serverConfig.key,
certPath: serverConfig.cert,
});

const {getSslCertificate, SslCertificateNotFoundError} = await import("@ui5/server/internal/sslUtil");
try {
const {key, cert} = await getSslCertificate(keyPath, certPath);
serverConfig.key = key;
serverConfig.cert = cert;
} catch (err) {
if (err instanceof SslCertificateNotFoundError) {
const keyOrigin = serverConfig.key ? "--key" : "default location";
const certOrigin = serverConfig.cert ? "--cert" : "default location";
throw new Error(
`Failed to find required SSL certificate for launching the HTTPS server.\n` +
`Looked for:\n` +
` Private key: ${formatPath(keyPath)} (${keyOrigin})\n` +
` Certificate: ${formatPath(certPath)} (${certOrigin})\n` +
`To fix this, either:\n` +
` • Run "ui5 certificate generate" to create and install one (recommended), or\n` +
` • Pass existing files with --key and --cert`
);
}
throw err;
}
}

const {promise: pOnError, reject} = Promise.withResolvers();
Expand Down
17 changes: 17 additions & 0 deletions packages/cli/lib/dataDir.js
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,23 @@ export async function getUi5DataDirOrDefault({cwd}) {
return (await getUi5DataDir({cwd})) ?? path.join(os.homedir(), ".ui5");
}

/**
* Resolves the paths of the server's private key and certificate. Explicit paths take precedence
* over the default paths within the given UI5 data directory.
*
* @param {string} ui5DataDir Absolute path to the UI5 data directory
* @param {object} [options]
* @param {string} [options.keyPath] Explicit private-key path
* @param {string} [options.certPath] Explicit certificate path
* @returns {{keyPath: string, certPath: string}} Private-key and certificate paths
*/
export function resolveServerCertificatePaths(ui5DataDir, {keyPath, certPath} = {}) {
return {
keyPath: keyPath ?? path.join(ui5DataDir, "server", "server.key"),
certPath: certPath ?? path.join(ui5DataDir, "server", "server.crt"),
};
}

/**
* Shortens an absolute path for display by replacing the user's home directory with
* <code>~</code> (e.g. <code>~/.ui5</code>). Intended for console and
Expand Down
9 changes: 7 additions & 2 deletions packages/cli/test/lib/cli/commands/cache.js
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,8 @@ test.beforeEach(async (t) => {
t.context.argv = getDefaultArgv();
t.context.stderrWriteStub = sinon.stub(process.stderr, "write");

// Prevent real env var from leaking into tests
// Tests rely on not having UI5_DATA_DIR defined
t.context.originalUi5DataDirEnv = process.env.UI5_DATA_DIR;
delete process.env.UI5_DATA_DIR;

t.context.getUi5DataDirOrDefaultStub = sinon.stub().resolves(TEST_UI5_DATA_DIR);
Expand Down Expand Up @@ -91,7 +92,11 @@ test.afterEach.always((t) => {
sinon.restore();
esmock.purge(t.context.cache);
process.exitCode = undefined;
delete process.env.UI5_DATA_DIR;
if (typeof t.context.originalUi5DataDirEnv === "undefined") {
delete process.env.UI5_DATA_DIR;
} else {
process.env.UI5_DATA_DIR = t.context.originalUi5DataDirEnv;
}
});

// ─── Command structure ──────────────────────────────────────────────────────
Expand Down
Loading
Loading