|
// retoor <retoor@molodetz.nl>
|
|
|
|
const fs = require("fs");
|
|
const http = require("http");
|
|
const https = require("https");
|
|
const vscode = require("vscode");
|
|
|
|
const AGENT_PATH = "/usr/bin/dpc";
|
|
const AGENT_TERMINAL = "DevPlace Code";
|
|
const SHELL_TERMINAL = "pravda@workspace";
|
|
const BOOT_KEY = "devplace.bootMarker";
|
|
const PANEL_STEPS = { short: 0, normal: 2, tall: 5, maximized: 0 };
|
|
const PUBLISH_TIMEOUT_MS = 20000;
|
|
|
|
class Profile {
|
|
constructor() {
|
|
this.data = Object.assign(
|
|
{
|
|
theme: "devplace-dark",
|
|
layout: "standard",
|
|
panel_preset: "tall",
|
|
boot_agent: "dpc",
|
|
boot_shell: true,
|
|
trust_all: true,
|
|
},
|
|
this.fromFile(),
|
|
this.fromEnv(),
|
|
);
|
|
}
|
|
|
|
fromFile() {
|
|
const path = process.env.DEVPLACE_EDITOR_PROFILE;
|
|
if (!path) return {};
|
|
try {
|
|
const parsed = JSON.parse(fs.readFileSync(path, "utf8"));
|
|
return parsed && parsed.editor ? parsed.editor : {};
|
|
} catch (error) {
|
|
return {};
|
|
}
|
|
}
|
|
|
|
fromEnv() {
|
|
const values = {};
|
|
if (process.env.DEVPLACE_EDITOR_PANEL_PRESET) {
|
|
values.panel_preset = process.env.DEVPLACE_EDITOR_PANEL_PRESET;
|
|
}
|
|
if (process.env.DEVPLACE_EDITOR_BOOT_AGENT) {
|
|
values.boot_agent = process.env.DEVPLACE_EDITOR_BOOT_AGENT;
|
|
}
|
|
if (process.env.DEVPLACE_EDITOR_BOOT_SHELL) {
|
|
values.boot_shell = process.env.DEVPLACE_EDITOR_BOOT_SHELL === "1";
|
|
}
|
|
return values;
|
|
}
|
|
|
|
get bootMarker() {
|
|
return process.env.DEVPLACE_CONTAINER_BOOT || `host-${process.pid}`;
|
|
}
|
|
|
|
get wantsAgent() {
|
|
return this.data.boot_agent === "dpc" && fs.existsSync(AGENT_PATH);
|
|
}
|
|
|
|
get wantsShell() {
|
|
return Boolean(this.data.boot_shell);
|
|
}
|
|
|
|
get panelPreset() {
|
|
return this.data.panel_preset || "tall";
|
|
}
|
|
}
|
|
|
|
class BootTerminals {
|
|
constructor(profile, memento) {
|
|
this.profile = profile;
|
|
this.memento = memento;
|
|
}
|
|
|
|
alreadyBooted() {
|
|
return this.memento.get(BOOT_KEY) === this.profile.bootMarker;
|
|
}
|
|
|
|
async open() {
|
|
if (this.alreadyBooted()) return false;
|
|
await this.memento.update(BOOT_KEY, this.profile.bootMarker);
|
|
const shell = this.profile.wantsShell ? this.createShell() : null;
|
|
const agent = this.profile.wantsAgent ? this.createAgent() : null;
|
|
if (agent) agent.show(true);
|
|
else if (shell) shell.show(true);
|
|
return Boolean(agent || shell);
|
|
}
|
|
|
|
createAgent() {
|
|
return vscode.window.createTerminal({
|
|
name: AGENT_TERMINAL,
|
|
shellPath: "/bin/bash",
|
|
shellArgs: ["-l", "-c", `exec ${AGENT_PATH}`],
|
|
iconPath: new vscode.ThemeIcon("rocket"),
|
|
isTransient: false,
|
|
});
|
|
}
|
|
|
|
createShell() {
|
|
return vscode.window.createTerminal({
|
|
name: SHELL_TERMINAL,
|
|
shellPath: "/bin/bash",
|
|
shellArgs: ["-l"],
|
|
iconPath: new vscode.ThemeIcon("terminal-bash"),
|
|
isTransient: false,
|
|
});
|
|
}
|
|
}
|
|
|
|
class Layout {
|
|
constructor(profile) {
|
|
this.profile = profile;
|
|
}
|
|
|
|
async apply(panelIsOpen) {
|
|
const preset = this.profile.panelPreset;
|
|
if (!panelIsOpen) return;
|
|
if (preset === "maximized") {
|
|
await vscode.commands.executeCommand("workbench.action.toggleMaximizedPanel");
|
|
return;
|
|
}
|
|
const steps = PANEL_STEPS[preset] === undefined ? 5 : PANEL_STEPS[preset];
|
|
for (let index = 0; index < steps; index += 1) {
|
|
await vscode.commands.executeCommand("workbench.action.increaseViewSize");
|
|
}
|
|
}
|
|
}
|
|
|
|
class Presence {
|
|
constructor(context) {
|
|
this.context = context;
|
|
this.item = vscode.window.createStatusBarItem(
|
|
vscode.StatusBarAlignment.Left,
|
|
100,
|
|
);
|
|
}
|
|
|
|
get projectTitle() {
|
|
return process.env.DEVPLACE_PROJECT_TITLE || "DevPlace";
|
|
}
|
|
|
|
register() {
|
|
this.item.text = `$(rocket) ${this.projectTitle}`;
|
|
this.item.tooltip = this.tooltip();
|
|
this.item.command = "devplace.openProject";
|
|
this.item.show();
|
|
this.context.subscriptions.push(this.item);
|
|
this.registerCommands();
|
|
}
|
|
|
|
tooltip() {
|
|
const owner = process.env.DEVPLACE_WORKSPACE_OWNER || "";
|
|
const name = process.env.DEVPLACE_TUNNEL_NAME || "";
|
|
return `DevPlace workspace ${name}${owner ? ` for ${owner}` : ""}`;
|
|
}
|
|
|
|
registerCommands() {
|
|
const commands = {
|
|
"devplace.runAgent": () => this.runAgent(),
|
|
"devplace.openProject": () => this.open(process.env.DEVPLACE_PROJECT_URL),
|
|
"devplace.openWorkspacePage": () =>
|
|
this.open(process.env.DEVPLACE_WORKSPACE_URL),
|
|
"devplace.openDocs": () => this.openDocs(),
|
|
"devplace.showTunnels": () => this.showTunnels(),
|
|
};
|
|
for (const [name, handler] of Object.entries(commands)) {
|
|
this.context.subscriptions.push(
|
|
vscode.commands.registerCommand(name, handler),
|
|
);
|
|
}
|
|
}
|
|
|
|
runAgent() {
|
|
const terminal = vscode.window.createTerminal({
|
|
name: AGENT_TERMINAL,
|
|
shellPath: AGENT_PATH,
|
|
iconPath: new vscode.ThemeIcon("rocket"),
|
|
});
|
|
terminal.show(true);
|
|
}
|
|
|
|
openDocs() {
|
|
const base = process.env.DEVPLACE_BASE_URL || "";
|
|
this.open(base ? `${base}/docs/workspace-editor.html` : "");
|
|
}
|
|
|
|
open(url) {
|
|
if (!url) {
|
|
vscode.window.showWarningMessage(
|
|
"DevPlace has not published a site URL for this workspace yet.",
|
|
);
|
|
return;
|
|
}
|
|
vscode.env.openExternal(vscode.Uri.parse(url));
|
|
}
|
|
|
|
async showTunnels() {
|
|
const path = process.env.DEVPLACE_TUNNEL_MANIFEST;
|
|
const rows = this.readManifest(path);
|
|
if (!rows.length) {
|
|
vscode.window.showInformationMessage(
|
|
"This workspace has no public tunnels yet.",
|
|
);
|
|
return;
|
|
}
|
|
const picked = await vscode.window.showQuickPick(
|
|
rows.map((row) => ({
|
|
label: row.label || row.hostname,
|
|
description: row.url,
|
|
detail: `port ${row.container_port} - ${row.status}`,
|
|
url: row.url,
|
|
})),
|
|
{ placeHolder: "Open a public tunnel" },
|
|
);
|
|
if (picked) this.open(picked.url);
|
|
}
|
|
|
|
readManifest(path) {
|
|
if (!path) return [];
|
|
try {
|
|
const parsed = JSON.parse(fs.readFileSync(path, "utf8"));
|
|
return Array.isArray(parsed.tunnels) ? parsed.tunnels : [];
|
|
} catch (error) {
|
|
return [];
|
|
}
|
|
}
|
|
}
|
|
|
|
class Tunnels {
|
|
constructor(output) {
|
|
this.output = output;
|
|
this.published = new Set();
|
|
this.base = (process.env.DEVPLACE_BASE_URL || "").replace(/\/+$/, "");
|
|
this.apiKey = process.env.DEVPLACE_API_KEY || "";
|
|
this.slug = process.env.DEVPLACE_PROJECT_SLUG || "";
|
|
this.editorPort = Number(process.env.DEVPLACE_EDITOR_PORT || 0);
|
|
}
|
|
|
|
get configured() {
|
|
return Boolean(this.base && this.apiKey && this.slug);
|
|
}
|
|
|
|
async watch(context) {
|
|
if (!this.configured) {
|
|
this.output.appendLine(
|
|
"tunnels: this workspace has no DevPlace credentials, so forwarded ports stay private",
|
|
);
|
|
return;
|
|
}
|
|
context.subscriptions.push(
|
|
vscode.workspace.onDidChangeTunnels(() =>
|
|
this.sync().catch((error) =>
|
|
this.output.appendLine(`tunnels: sync failed: ${error}`),
|
|
),
|
|
),
|
|
);
|
|
await this.sync();
|
|
}
|
|
|
|
async sync() {
|
|
const rows = (await vscode.workspace.tunnels) || [];
|
|
for (const row of rows) {
|
|
const port = Number((row.remoteAddress || {}).port || 0);
|
|
if (!port || port === this.editorPort) continue;
|
|
if (this.published.has(port)) continue;
|
|
await this.publish(port);
|
|
}
|
|
}
|
|
|
|
async publish(port) {
|
|
this.published.add(port);
|
|
let answer;
|
|
try {
|
|
answer = await this.post(port);
|
|
} catch (error) {
|
|
this.published.delete(port);
|
|
this.output.appendLine(`tunnels: port ${port} could not be published: ${error}`);
|
|
return;
|
|
}
|
|
if (answer.status >= 400) {
|
|
this.published.delete(port);
|
|
this.output.appendLine(
|
|
`tunnels: DevPlace refused port ${port} (${answer.status}): ${answer.body.slice(0, 300)}`,
|
|
);
|
|
vscode.window.showWarningMessage(
|
|
`DevPlace could not publish port ${port}: ${this.refusal(answer.body)}`,
|
|
);
|
|
return;
|
|
}
|
|
const url = this.publishedUrl(answer.body, port);
|
|
this.output.appendLine(`tunnels: port ${port} is published at ${url}`);
|
|
vscode.window.showInformationMessage(
|
|
`Port ${port} is published at ${url}. It serves HTTPS once its certificate is issued.`,
|
|
);
|
|
}
|
|
|
|
post(port) {
|
|
const url = new URL(
|
|
`${this.base}/projects/${encodeURIComponent(this.slug)}/workspace/tunnels`,
|
|
);
|
|
const body = new URLSearchParams({
|
|
label: `Port ${port}`,
|
|
container_port: String(port),
|
|
}).toString();
|
|
const client = url.protocol === "https:" ? https : http;
|
|
const options = {
|
|
method: "POST",
|
|
headers: {
|
|
"Content-Type": "application/x-www-form-urlencoded",
|
|
"Content-Length": Buffer.byteLength(body),
|
|
Accept: "application/json",
|
|
"X-API-KEY": this.apiKey,
|
|
},
|
|
timeout: PUBLISH_TIMEOUT_MS,
|
|
};
|
|
return new Promise((resolve, reject) => {
|
|
const call = client.request(url, options, (response) => {
|
|
const chunks = [];
|
|
response.on("data", (chunk) => chunks.push(chunk));
|
|
response.on("end", () =>
|
|
resolve({
|
|
status: response.statusCode,
|
|
body: Buffer.concat(chunks).toString("utf8"),
|
|
}),
|
|
);
|
|
});
|
|
call.on("timeout", () => call.destroy(new Error("request timed out")));
|
|
call.on("error", reject);
|
|
call.end(body);
|
|
});
|
|
}
|
|
|
|
refusal(body) {
|
|
try {
|
|
const parsed = JSON.parse(body);
|
|
return (parsed.error && parsed.error.message) || "the request was refused";
|
|
} catch (error) {
|
|
return "the request was refused";
|
|
}
|
|
}
|
|
|
|
publishedUrl(body, port) {
|
|
try {
|
|
const parsed = JSON.parse(body);
|
|
const hostname = parsed.data && parsed.data.hostname;
|
|
if (hostname) return `https://${hostname}`;
|
|
} catch (error) {
|
|
/* fall through to the pattern below */
|
|
}
|
|
const pattern =
|
|
process.env.DEVPLACE_TUNNEL_PORT_PATTERN || "{port}-{name}.{domain}";
|
|
return `https://${pattern
|
|
.replace("{port}", String(port))
|
|
.replace("{name}", process.env.DEVPLACE_TUNNEL_NAME || "")
|
|
.replace("{domain}", process.env.DEVPLACE_TUNNEL_DOMAIN || "")}`;
|
|
}
|
|
}
|
|
|
|
async function stage(output, name, run) {
|
|
try {
|
|
return await run();
|
|
} catch (error) {
|
|
output.appendLine(`${name} failed: ${error}`);
|
|
return undefined;
|
|
}
|
|
}
|
|
|
|
async function activate(context) {
|
|
const output = vscode.window.createOutputChannel("DevPlace");
|
|
context.subscriptions.push(output);
|
|
const profile = new Profile();
|
|
|
|
await stage(output, "presence", () => new Presence(context).register());
|
|
const opened = await stage(output, "terminals", () =>
|
|
new BootTerminals(profile, context.workspaceState).open(),
|
|
);
|
|
await stage(output, "layout", () => new Layout(profile).apply(Boolean(opened)));
|
|
await stage(output, "tunnels", () => new Tunnels(output).watch(context));
|
|
}
|
|
|
|
function deactivate() {}
|
|
|
|
module.exports = { activate, deactivate };
|