Skip to content
Draft
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
5 changes: 4 additions & 1 deletion docs/command-graph.html
Original file line number Diff line number Diff line change
Expand Up @@ -444,6 +444,9 @@
{ id: "sdk_publish", label: "publish (beta)", group: "command", path: "sdk publish",
description: "Publish a build of a custom app as a new version of its Codespace asset.",
options: ["-p, --profile <profile>", "--appId <appId>", "--dist <dist>", "-h, --help"] },
{ id: "sdk_preview", label: "preview (beta)", group: "command", path: "sdk preview",
description: "Serve a local dev server to the Codespace's Local preview with the published app's sandbox rules",
options: ["--target <target>", "--port <port>", "--allow-inline-scripts", "-h, --help"] },

// git profile
{ id: "git_profile_area", label: "profile", group: "subarea", path: "git profile",
Expand Down Expand Up @@ -546,7 +549,7 @@
["deployment_list_area","deployment_list_history"],["deployment_list_area","deployment_list_active"],
["deployment_list_area","deployment_list_deployables"],["deployment_list_area","deployment_list_targets"],

["area_sdk","sdk_download"],["area_sdk","sdk_publish"],
["area_sdk","sdk_download"],["area_sdk","sdk_publish"],["area_sdk","sdk_preview"],

["area_git","git_profile_area"],["git_profile_area","git_profile_list"],
["git_profile_area","git_profile_create"],["git_profile_area","git_profile_default"],
Expand Down
16 changes: 16 additions & 0 deletions docs/user-guide/sdk-commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,3 +27,19 @@ content-cli sdk publish --appId <appId> --dist <dist>
```

The command compresses the given build directory and uploads it for the app. On success, the version assigned to the build is printed, which can then be selected on the Codespace asset.

## Preview App

Serve a local dev server to the Local preview of a Codespace asset, with the same sandbox rules as the published app.

```
content-cli sdk preview [--target <target>] [--port <port>] [--allow-inline-scripts]
```

- `--target` is the origin of your app's dev server. Defaults to `http://localhost:4200`.
- `--port` is the HTTPS port the Local preview loads. Defaults to `4300`.
- `--allow-inline-scripts` allows inline scripts in your dev server's HTML pages. Off by default.

The command does not need a profile. It serves your dev server over HTTPS with a self-signed certificate stored in `~/.celonis-content-cli-preview`, and forwards requests and WebSocket connections to the target. If the Local preview stays blank, open the printed `https://localhost:<port>` URL in a browser tab, accept the certificate, and reload the preview. Press Ctrl+C to stop.

Published apps block inline scripts, and so does the Local preview by default. Some dev servers inject one, such as React's Fast Refresh preamble, and the app stays blank without it. Pass `--allow-inline-scripts` to allow them: the command adds a hash of each inline script to the page's `script-src`. Either way it prints a warning for each inline script the first time it sees it. Move any inline script of your own into a separate file before publishing.
10 changes: 6 additions & 4 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,17 +18,19 @@
"node": ">=18.20.5"
},
"dependencies": {
"@github/keytar": "7.10.6",
"adm-zip": "0.6.0",
"chalk": "4.1.2",
"uuid": "9.0.1",
"axios": "1.18.0",
"chalk": "4.1.2",
"commander": "13.1.0",
"form-data": "4.0.6",
"openid-client": "5.6.1",
"hpagent": "1.2.0",
"@github/keytar": "7.10.6",
"http-proxy-middleware": "3.0.5",
"openid-client": "5.6.1",
"selfsigned": "5.5.0",
"semver": "7.6.3",
"simple-git": "3.36.0",
"uuid": "9.0.1",
"valid-url": "1.0.9",
"winston": "3.17.0",
"yaml": "2.8.3"
Expand Down
25 changes: 25 additions & 0 deletions src/commands/sdk/module.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { Configurator, IModule } from "../../core/command/module-handler";
import { Context } from "../../core/command/cli-context";
import { Command, OptionValues } from "commander";
import { SdkPreviewService } from "./sdk-preview.service";
import { SdkService } from "./sdk.service";

class Module extends IModule {
Expand All @@ -24,6 +25,16 @@ class Module extends IModule {
.requiredOption("--appId <appId>", "Identifier of the app to publish the build for")
.requiredOption("--dist <dist>", "Directory containing the build of the app")
.action(this.publishApp);

sdkCommand
.command("preview")
.beta()
.skipProfileLoading()
.description("Serve a local dev server to the Codespace's Local preview with the published app's sandbox rules")
.option("--target <target>", "URL of your app's dev server", "http://localhost:4200")
.option("--port <port>", "HTTPS port the Codespace preview loads", "4300")
.option("--allow-inline-scripts", "Allow inline scripts in your dev server's HTML")
.action(this.previewApp);
}

private async downloadSdk(context: Context, command: Command, options: OptionValues): Promise<void> {
Expand All @@ -33,6 +44,20 @@ class Module extends IModule {
private async publishApp(context: Context, command: Command, options: OptionValues): Promise<void> {
await new SdkService(context).publishApp(options.appId, options.dist);
}

private async previewApp(context: Context, command: Command, options: OptionValues): Promise<void> {
const proxy = await new SdkPreviewService().preview(options.target, options.port, options.allowInlineScripts === true);
await new Promise<void>(resolve => {
const stop = (): void => {
process.removeListener("SIGINT", stop);
process.removeListener("SIGTERM", stop);
resolve();
};
process.once("SIGINT", stop);
process.once("SIGTERM", stop);
});
await proxy.close();
}
}

export = Module;
66 changes: 66 additions & 0 deletions src/commands/sdk/preview-certificate.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import { createPrivateKey, X509Certificate } from "node:crypto";
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { generate } from "selfsigned";

export interface PreviewCertificate {
key: string;
cert: string;
}

const DAY_MS = 24 * 60 * 60 * 1000;
const CERT_FILE = "localhost-cert.pem";
const KEY_FILE = "localhost-key.pem";

export async function loadOrCreatePreviewCertificate(directory: string, now: Date = new Date()): Promise<PreviewCertificate> {
const existing = readUsableCertificate(directory, now);
if (existing) {
return existing;
}

const generated = await generate([{ name: "commonName", value: "localhost" }], {
keyType: "ec",
curve: "P-256",
algorithm: "sha256",
notBeforeDate: now,
notAfterDate: new Date(now.getTime() + 365 * DAY_MS),
extensions: [
{
name: "subjectAltName",
altNames: [
{ type: 2, value: "localhost" },
{ type: 7, ip: "127.0.0.1" },
{ type: 7, ip: "::1" },
],
},
],
});
const certificate: PreviewCertificate = { key: generated.private, cert: generated.cert };

mkdirSync(directory, { recursive: true });
writeFileSync(join(directory, CERT_FILE), certificate.cert);
writeFileSync(join(directory, KEY_FILE), certificate.key, { mode: 0o600 });
return certificate;
}

function readUsableCertificate(directory: string, now: Date): PreviewCertificate | null {
const certPath = join(directory, CERT_FILE);
const keyPath = join(directory, KEY_FILE);
if (!existsSync(certPath) || !existsSync(keyPath)) {
return null;
}
try {
const cert = readFileSync(certPath, "utf8");
const key = readFileSync(keyPath, "utf8");
const x509 = new X509Certificate(cert);
if (!x509.checkPrivateKey(createPrivateKey(key))) {
return null;
}
if (new Date(x509.validTo).getTime() - now.getTime() <= DAY_MS) {
return null;
}
return { key, cert };
} catch {
return null;
}
}
14 changes: 14 additions & 0 deletions src/commands/sdk/preview-headers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
/**
* Copied from `localPreviewHeaders()` in ems-frontend `libs/common/fragments/sandbox/src/lib/policy.ts`,
* whose spec fails when the gateway policy drifts. Update both together.
*/
export const LOCAL_PREVIEW_HEADERS: Readonly<Record<string, string>> = {
"content-security-policy":
"default-src 'none'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self'; media-src 'self' blob:; connect-src 'none'; worker-src 'none'; frame-src 'none'; object-src 'none'; base-uri 'self'; form-action 'none'; frame-ancestors https://*.celonis.cloud http://localhost:*; sandbox allow-scripts",
"permissions-policy":
"camera=(), microphone=(), geolocation=(), payment=(), usb=(), serial=(), hid=(), clipboard-read=(), clipboard-write=(), display-capture=()",
"access-control-allow-origin": "*",
"referrer-policy": "no-referrer",
"x-content-type-options": "nosniff",
"cache-control": "no-store",
};
45 changes: 45 additions & 0 deletions src/commands/sdk/preview-inline-scripts.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import { createHash } from "node:crypto";

export interface InlineScript {
/** `'sha256-<base64>'`, quoted and ready for a CSP source list. */
hash: string;
/** The first 60 characters of the script text, with whitespace collapsed. */
excerpt: string;
}

const SCRIPT_ELEMENT = /<script\b([^>]*)>([\s\S]*?)<\/script\s*>/gi;
const SRC_ATTRIBUTE = /(?:^|[\s/])src(?=[\s=/]|$)/i;
const EXCERPT_LENGTH = 60;

/**
* The inline scripts (`<script>` without `src`) of an HTML document, distinct by hash and in document order.
* Each hash covers the script text as the browser parses it for CSP (line endings normalized to LF).
*/
export function inlineScriptHashes(html: string): InlineScript[] {
const scripts = new Map<string, InlineScript>();
for (const [, attributes, text] of html.matchAll(SCRIPT_ELEMENT)) {
if (SRC_ATTRIBUTE.test(attributes) || text.length === 0) {
continue;
}
// Browsers hash the parsed text, and the HTML parser turns CRLF and lone CR into LF.
const parsedText = text.replace(/\r\n?/g, "\n");
const hash = `'sha256-${createHash("sha256").update(parsedText, "utf8").digest("base64")}'`;
if (!scripts.has(hash)) {
scripts.set(hash, { hash, excerpt: text.replace(/\s+/g, " ").trim().slice(0, EXCERPT_LENGTH) });
}
}
return [...scripts.values()];
}

/** Appends the hashes to the policy's `script-src` directive and leaves every other directive unchanged. */
export function allowInlineScripts(csp: string, hashes: readonly string[]): string {
if (hashes.length === 0) {
return csp;
}
return csp
.split(";")
.map(directive => /^\s*script-src(\s|$)/i.test(directive)
? `${directive.trimEnd()} ${hashes.join(" ")}`
: directive)
.join(";");
}
Loading
Loading