@playporter/cli 0.3.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/dist/index.d.ts +2 -0
- package/dist/index.js +698 -0
- package/dist/index.js.map +1 -0
- package/package.json +26 -0
- package/src/index.ts +723 -0
- package/tsconfig.json +5 -0
package/src/index.ts
ADDED
|
@@ -0,0 +1,723 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { createHash, generateKeyPairSync, sign as signDetached } from "node:crypto";
|
|
3
|
+
import { createWriteStream } from "node:fs";
|
|
4
|
+
import { readFile, writeFile, mkdir } from "node:fs/promises";
|
|
5
|
+
import { homedir } from "node:os";
|
|
6
|
+
import path from "node:path";
|
|
7
|
+
import { spawn } from "node:child_process";
|
|
8
|
+
import process from "node:process";
|
|
9
|
+
import archiver from "archiver";
|
|
10
|
+
import { Command } from "commander";
|
|
11
|
+
import fs from "fs-extra";
|
|
12
|
+
import { parse as parseYaml, stringify as stringifyYaml } from "yaml";
|
|
13
|
+
import type { ArtifactFileEntry, ArtifactManifest, ArtifactSignature, PlayPorterConfig, PublicPlatform, ValidationReportData } from "@playporter/core";
|
|
14
|
+
import { validateBundle } from "@playporter/validator";
|
|
15
|
+
|
|
16
|
+
const program = new Command();
|
|
17
|
+
program.name("playporter").description("Build locally. Launch socially. Operate everywhere.").version("0.2.0");
|
|
18
|
+
|
|
19
|
+
program.command("init")
|
|
20
|
+
.description("Create playporter.yml and an SDK bootstrap example")
|
|
21
|
+
.option("--force", "overwrite existing files")
|
|
22
|
+
.option("--json", "emit playporter.config.json instead of playporter.yml")
|
|
23
|
+
.action(async ({ force, json }: { force?: boolean; json?: boolean }) => {
|
|
24
|
+
const useYaml = !json;
|
|
25
|
+
const configPath = path.resolve(useYaml ? "playporter.yml" : "playporter.config.json");
|
|
26
|
+
if (await fs.pathExists(configPath) && !force) throw new Error(`${path.basename(configPath)} already exists; use --force to replace it.`);
|
|
27
|
+
const packageJson = await readPackageJson();
|
|
28
|
+
const slug = slugify(packageJson.name ?? path.basename(process.cwd()));
|
|
29
|
+
const config: PlayPorterConfig = {
|
|
30
|
+
schemaVersion: 1,
|
|
31
|
+
game: { slug, name: packageJson.name ?? slug, version: packageJson.version ?? "0.2.0" },
|
|
32
|
+
build: { command: "npm run build", output: "dist" },
|
|
33
|
+
api: { baseUrl: "http://localhost:3000", projectKeyEnv: "PLAYPORTER_PROJECT_KEY" },
|
|
34
|
+
platforms: {
|
|
35
|
+
discord: { enabled: true, clientId: "", urlMappings: [] },
|
|
36
|
+
telegram: { enabled: true, botUsername: "" },
|
|
37
|
+
reddit: { enabled: true, appName: redditName(slug), subreddit: `${slug}_dev`, postTitle: packageJson.name ?? "PlayPorter Game" },
|
|
38
|
+
},
|
|
39
|
+
services: {
|
|
40
|
+
auth: { primary: "playporter" },
|
|
41
|
+
saves: { primary: "playporter" },
|
|
42
|
+
payments: { primary: "playporter" },
|
|
43
|
+
analytics: { primary: "playporter" },
|
|
44
|
+
},
|
|
45
|
+
};
|
|
46
|
+
if (useYaml) {
|
|
47
|
+
await writeFile(configPath, yamlHeader() + stringifyYaml(config, { indent: 2 }), "utf8");
|
|
48
|
+
} else {
|
|
49
|
+
await writeJson(configPath, config);
|
|
50
|
+
}
|
|
51
|
+
const samplePath = path.resolve("playporter.bootstrap.example.ts");
|
|
52
|
+
if (!(await fs.pathExists(samplePath)) || force) await writeFile(samplePath, bootstrapExample(), "utf8");
|
|
53
|
+
console.log(`Created ${path.relative(process.cwd(), configPath)} and ${path.basename(samplePath)}.`);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
program.command("build")
|
|
57
|
+
.description("Run the configured local build without uploading source code")
|
|
58
|
+
.option("-c, --config <path>", "config file (default: playporter.yml or playporter.config.json)")
|
|
59
|
+
.option("--skip-validate", "do not validate after build")
|
|
60
|
+
.action(async ({ config: configPath, skipValidate }: { config?: string; skipValidate?: boolean }) => {
|
|
61
|
+
const config = await loadConfig(configPath);
|
|
62
|
+
console.log(`Running locally: ${config.build.command}`);
|
|
63
|
+
await runShell(config.build.command);
|
|
64
|
+
if (!skipValidate) {
|
|
65
|
+
const report = await validateBundle({ bundlePath: config.build.output, config });
|
|
66
|
+
printReport(report);
|
|
67
|
+
if (!report.passed) process.exitCode = 2;
|
|
68
|
+
}
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
program.command("validate")
|
|
72
|
+
.description("Validate the compiled bundle for Discord, Telegram and Reddit")
|
|
73
|
+
.option("-c, --config <path>", "config file (default: playporter.yml or playporter.config.json)")
|
|
74
|
+
.option("--upload", "send only the validation report to PlayPorter Studio")
|
|
75
|
+
.action(async ({ config: configPath, upload }: { config?: string; upload?: boolean }) => {
|
|
76
|
+
const config = await loadConfig(configPath);
|
|
77
|
+
const report = await validateBundle({ bundlePath: config.build.output, config });
|
|
78
|
+
printReport(report);
|
|
79
|
+
await fs.ensureDir(".playporter");
|
|
80
|
+
await writeJson(path.resolve(".playporter/validation-report.json"), report);
|
|
81
|
+
if (upload) await uploadReport(config, report);
|
|
82
|
+
if (!report.passed) process.exitCode = 2;
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
program.command("artifact")
|
|
86
|
+
.description("Generate a signed .playporter artifact from the compiled bundle")
|
|
87
|
+
.option("-c, --config <path>", "config file (default: playporter.yml or playporter.config.json)")
|
|
88
|
+
.option("-o, --output <path>", "artifact path")
|
|
89
|
+
.action(async ({ config: configPath, output }: { config?: string; output?: string }) => {
|
|
90
|
+
const config = await loadConfig(configPath);
|
|
91
|
+
const report = await validateBundle({ bundlePath: config.build.output, config });
|
|
92
|
+
printReport(report);
|
|
93
|
+
if (!report.passed) throw new Error("Artifact generation stopped because validation contains errors.");
|
|
94
|
+
const artifactPath = path.resolve(output ?? `${config.game.slug}-${config.game.version}.playporter`);
|
|
95
|
+
const artifact = await createSignedArtifact(config, report, artifactPath);
|
|
96
|
+
console.log(`Signed artifact created: ${artifactPath}`);
|
|
97
|
+
console.log(`Manifest SHA-256: ${artifact.signature.manifestSha256}`);
|
|
98
|
+
console.log("PlayPorter can validate and distribute this artifact without access to your source code.");
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
program.command("package")
|
|
102
|
+
.description("Generate platform-specific deployable packages from the compiled bundle")
|
|
103
|
+
.option("-c, --config <path>", "config file (default: playporter.yml or playporter.config.json)")
|
|
104
|
+
.option("-p, --platform <name>", "discord, telegram, reddit or all", "all")
|
|
105
|
+
.option("-o, --output <path>", "output directory", "playporter-output")
|
|
106
|
+
.option("--zip", "also create zip archives")
|
|
107
|
+
.action(async (options: { config?: string; platform: string; output: string; zip?: boolean }) => {
|
|
108
|
+
const config = await loadConfig(options.config);
|
|
109
|
+
const report = await validateBundle({ bundlePath: config.build.output, config });
|
|
110
|
+
printReport(report);
|
|
111
|
+
if (!report.passed) throw new Error("Packaging stopped because validation contains errors.");
|
|
112
|
+
const platforms = resolvePlatforms(options.platform, config);
|
|
113
|
+
const root = path.resolve(options.output, `${config.game.slug}-${config.game.version}`);
|
|
114
|
+
await fs.emptyDir(root);
|
|
115
|
+
const artifact = await createSignedArtifact(config, report, path.join(root, `${config.game.slug}-${config.game.version}.playporter`));
|
|
116
|
+
for (const platform of platforms) {
|
|
117
|
+
const destination = path.join(root, platform);
|
|
118
|
+
if (platform === "reddit") await generateRedditPackage(config, destination, report);
|
|
119
|
+
else await generateHostedPackage(platform, config, destination, report);
|
|
120
|
+
await writeJson(path.join(destination, "playporter-artifact.json"), {
|
|
121
|
+
artifactId: artifact.manifest.artifactId,
|
|
122
|
+
manifestSha256: artifact.signature.manifestSha256,
|
|
123
|
+
publicKeyPem: artifact.signature.publicKeyPem,
|
|
124
|
+
sourceCodeIncluded: false,
|
|
125
|
+
});
|
|
126
|
+
if (options.zip) await zipDirectory(destination, `${destination}.zip`);
|
|
127
|
+
console.log(`Generated ${platform}: ${destination}`);
|
|
128
|
+
}
|
|
129
|
+
console.log("Only compiled assets and deployment metadata were packaged; source files were not copied.");
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
// ─── playporter login ─────────────────────────────────────────────────────────
|
|
133
|
+
|
|
134
|
+
program.command("login")
|
|
135
|
+
.description("Authenticate the CLI with PlayPorter Studio")
|
|
136
|
+
.option("-c, --config <path>", "config file (default: playporter.yml)")
|
|
137
|
+
.option("--url <studio-url>", "Studio base URL (overrides config)")
|
|
138
|
+
.action(async (options: { config?: string; url?: string }) => {
|
|
139
|
+
const config = await loadConfig(options.config).catch(() => null);
|
|
140
|
+
const studioUrl = (options.url ?? config?.api.baseUrl ?? "https://studio.playporter.net").replace(/\/$/, "");
|
|
141
|
+
|
|
142
|
+
const readline = await import("node:readline");
|
|
143
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
144
|
+
const ask = (q: string) => new Promise<string>((resolve) => rl.question(q, resolve));
|
|
145
|
+
|
|
146
|
+
console.log(`\nLogging in to ${studioUrl}\n`);
|
|
147
|
+
const email = await ask("Email: ");
|
|
148
|
+
const password = await ask("Password: ");
|
|
149
|
+
rl.close();
|
|
150
|
+
|
|
151
|
+
const res = await fetch(`${studioUrl}/api/v1/admin/cli/auth`, {
|
|
152
|
+
method: "POST",
|
|
153
|
+
headers: { "Content-Type": "application/json" },
|
|
154
|
+
body: JSON.stringify({ email: email.trim(), password }),
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
if (!res.ok) {
|
|
158
|
+
const err = await res.json().catch(() => ({})) as { error?: string };
|
|
159
|
+
throw new Error(`Login failed: ${err.error ?? res.statusText}`);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const data = await res.json() as { token: string; email: string; name: string };
|
|
163
|
+
const credDir = path.join(homedir(), ".config", "playporter");
|
|
164
|
+
const credPath = path.join(credDir, "credentials.json");
|
|
165
|
+
await mkdir(credDir, { recursive: true });
|
|
166
|
+
await writeFile(credPath, JSON.stringify({ studioUrl, token: data.token, email: data.email, name: data.name }, null, 2), "utf8");
|
|
167
|
+
|
|
168
|
+
console.log(`\n✓ Logged in as ${data.name ?? data.email}`);
|
|
169
|
+
console.log(` Credentials saved to ${credPath}`);
|
|
170
|
+
console.log(` Run \`playporter deploy\` to deploy your game.\n`);
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
// ─── playporter deploy ────────────────────────────────────────────────────────
|
|
174
|
+
|
|
175
|
+
program.command("deploy")
|
|
176
|
+
.description("Build, package and auto-deploy to all configured platforms via PlayPorter Studio")
|
|
177
|
+
.option("-c, --config <path>", "config file")
|
|
178
|
+
.option("--skip-build", "skip the build step (use existing output directory)")
|
|
179
|
+
.option("-p, --platforms <list>", "comma-separated platform list (default: all enabled + web)")
|
|
180
|
+
.action(async (options: { config?: string; skipBuild?: boolean; platforms?: string }) => {
|
|
181
|
+
const config = await loadConfig(options.config);
|
|
182
|
+
const apiBase = config.api.baseUrl.replace(/\/$/, "");
|
|
183
|
+
|
|
184
|
+
// Load credentials from ~/.config/playporter/credentials.json
|
|
185
|
+
const credPath = path.join(homedir(), ".config", "playporter", "credentials.json");
|
|
186
|
+
let adminToken: string | null = null;
|
|
187
|
+
try {
|
|
188
|
+
const creds = JSON.parse(await readFile(credPath, "utf8")) as { token?: string; studioUrl?: string };
|
|
189
|
+
if (creds.studioUrl && creds.studioUrl !== apiBase) {
|
|
190
|
+
console.warn(`Warning: credentials were created for ${creds.studioUrl} but config uses ${apiBase}.`);
|
|
191
|
+
}
|
|
192
|
+
adminToken = creds.token ?? null;
|
|
193
|
+
} catch {
|
|
194
|
+
throw new Error("Not logged in. Run `playporter login` first.");
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
const headers: Record<string, string> = { Authorization: `Bearer ${adminToken}` };
|
|
198
|
+
|
|
199
|
+
// 1. Build
|
|
200
|
+
if (!options.skipBuild && config.build.command) {
|
|
201
|
+
console.log(`\n▸ Building: ${config.build.command}`);
|
|
202
|
+
await runShell(config.build.command);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// 2. Validate + upload
|
|
206
|
+
console.log("▸ Validating bundle...");
|
|
207
|
+
const report = await validateBundle({ bundlePath: config.build.output, config });
|
|
208
|
+
printReport(report);
|
|
209
|
+
if (!report.passed) throw new Error("Validation failed. Fix the issues above before deploying.");
|
|
210
|
+
|
|
211
|
+
const projectKey = process.env[config.api.projectKeyEnv];
|
|
212
|
+
if (projectKey) await uploadReport(config, report);
|
|
213
|
+
|
|
214
|
+
// 3. Package into temp ZIPs
|
|
215
|
+
const tmpDir = `/tmp/pp-deploy-${Date.now()}`;
|
|
216
|
+
await fs.ensureDir(tmpDir);
|
|
217
|
+
|
|
218
|
+
const allPlatforms: string[] = options.platforms
|
|
219
|
+
? options.platforms.split(",").map((p) => p.trim()).filter(Boolean)
|
|
220
|
+
: [...resolvePlatforms("all", config), "web"];
|
|
221
|
+
|
|
222
|
+
console.log(`\n▸ Packaging: ${allPlatforms.join(", ")}`);
|
|
223
|
+
|
|
224
|
+
const artifactIds: Record<string, string> = {};
|
|
225
|
+
|
|
226
|
+
for (const platform of allPlatforms) {
|
|
227
|
+
const dest = path.join(tmpDir, platform);
|
|
228
|
+
if (platform === "web") {
|
|
229
|
+
await zipDirectory(path.resolve(config.build.output), `${dest}.zip`);
|
|
230
|
+
} else if (platform === "reddit") {
|
|
231
|
+
await generateRedditPackage(config, dest, report);
|
|
232
|
+
await zipDirectory(dest, `${dest}.zip`);
|
|
233
|
+
} else {
|
|
234
|
+
await generateHostedPackage(platform as "discord" | "telegram", config, dest, report);
|
|
235
|
+
await zipDirectory(dest, `${dest}.zip`);
|
|
236
|
+
}
|
|
237
|
+
artifactIds[platform] = await uploadArtifactFile(`${dest}.zip`, apiBase, headers);
|
|
238
|
+
console.log(` ✓ ${platform} packaged and uploaded`);
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// 4. Execute deploy via SSE
|
|
242
|
+
const params = new URLSearchParams({ platforms: allPlatforms.join(",") });
|
|
243
|
+
for (const [plat, id] of Object.entries(artifactIds)) params.set(plat, id);
|
|
244
|
+
|
|
245
|
+
console.log("\n▸ Deploying to all platforms...\n");
|
|
246
|
+
await streamDeployProgress(`${apiBase}/api/v1/deploy/execute?${params}`, headers);
|
|
247
|
+
|
|
248
|
+
// Cleanup
|
|
249
|
+
await fs.remove(tmpDir).catch(() => null);
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
async function uploadArtifactFile(
|
|
253
|
+
zipPath: string,
|
|
254
|
+
apiBase: string,
|
|
255
|
+
headers: Record<string, string>
|
|
256
|
+
): Promise<string> {
|
|
257
|
+
const fileBytes = await readFile(zipPath);
|
|
258
|
+
const form = new FormData();
|
|
259
|
+
form.append("file", new Blob([fileBytes], { type: "application/zip" }), path.basename(zipPath));
|
|
260
|
+
const res = await fetch(`${apiBase}/api/v1/deploy/artifact`, {
|
|
261
|
+
method: "POST",
|
|
262
|
+
headers,
|
|
263
|
+
body: form,
|
|
264
|
+
});
|
|
265
|
+
if (!res.ok) throw new Error(`Artifact upload failed (${res.status}): ${await res.text()}`);
|
|
266
|
+
const data = await res.json() as { artifactId: string };
|
|
267
|
+
return data.artifactId;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
async function streamDeployProgress(url: string, headers: Record<string, string>): Promise<void> {
|
|
271
|
+
const res = await fetch(url, { headers: { ...headers, Accept: "text/event-stream" } });
|
|
272
|
+
if (!res.ok || !res.body) throw new Error(`Deploy request failed (${res.status}): ${await res.text()}`);
|
|
273
|
+
|
|
274
|
+
const decoder = new TextDecoder();
|
|
275
|
+
const reader = res.body.getReader();
|
|
276
|
+
let buf = "";
|
|
277
|
+
let allOk = false;
|
|
278
|
+
|
|
279
|
+
const PLATFORM_ICON: Record<string, string> = {
|
|
280
|
+
web: "🌐", discord: "💬", telegram: "📱", reddit: "🔴",
|
|
281
|
+
};
|
|
282
|
+
|
|
283
|
+
while (true) {
|
|
284
|
+
const { done, value } = await reader.read();
|
|
285
|
+
if (done) break;
|
|
286
|
+
buf += decoder.decode(value, { stream: true });
|
|
287
|
+
const lines = buf.split("\n");
|
|
288
|
+
buf = lines.pop() ?? "";
|
|
289
|
+
let event = "";
|
|
290
|
+
for (const line of lines) {
|
|
291
|
+
if (line.startsWith("event: ")) { event = line.slice(7).trim(); continue; }
|
|
292
|
+
if (!line.startsWith("data: ")) continue;
|
|
293
|
+
const data = JSON.parse(line.slice(6)) as Record<string, unknown>;
|
|
294
|
+
if (event.includes(":started")) {
|
|
295
|
+
const plat = event.split(":")[1];
|
|
296
|
+
const icon = PLATFORM_ICON[plat] ?? "▸";
|
|
297
|
+
process.stdout.write(` ${icon} Deploying ${plat}...`);
|
|
298
|
+
} else if (event.includes(":success")) {
|
|
299
|
+
const plat = event.split(":")[1];
|
|
300
|
+
const u = (data as { url?: string }).url;
|
|
301
|
+
process.stdout.write(`\r ✓ ${plat} deployed${u ? ` → ${u}` : ""}\n`);
|
|
302
|
+
} else if (event.includes(":error")) {
|
|
303
|
+
const plat = event.split(":")[1];
|
|
304
|
+
process.stdout.write(`\r ✗ ${plat} failed: ${(data as { error?: string }).error ?? "unknown error"}\n`);
|
|
305
|
+
} else if (event === "deploy:complete") {
|
|
306
|
+
allOk = (data as { success?: boolean }).success === true;
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
console.log(allOk ? "\n✓ Deployment complete." : "\n⚠ Deployment finished with errors.");
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
program.parseAsync().catch((error: unknown) => {
|
|
315
|
+
console.error(error instanceof Error ? error.message : error);
|
|
316
|
+
process.exitCode = 1;
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
async function loadConfig(configPath?: string): Promise<PlayPorterConfig> {
|
|
320
|
+
const resolved = await resolveConfigPath(configPath);
|
|
321
|
+
const raw = await readFile(resolved, "utf8");
|
|
322
|
+
const config = /\.(yml|yaml)$/i.test(resolved)
|
|
323
|
+
? (parseYaml(raw) as PlayPorterConfig)
|
|
324
|
+
: (JSON.parse(raw) as PlayPorterConfig);
|
|
325
|
+
if (config.schemaVersion !== 1) throw new Error(`Unsupported schemaVersion in ${path.basename(resolved)}.`);
|
|
326
|
+
return config;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
async function resolveConfigPath(explicit?: string): Promise<string> {
|
|
330
|
+
if (explicit) return path.resolve(explicit);
|
|
331
|
+
for (const candidate of ["playporter.yml", "playporter.yaml", "playporter.config.json"]) {
|
|
332
|
+
const abs = path.resolve(candidate);
|
|
333
|
+
if (await fs.pathExists(abs)) return abs;
|
|
334
|
+
}
|
|
335
|
+
throw new Error("No config file found. Run `playporter init` to create one.");
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
async function readPackageJson(): Promise<{ name?: string; version?: string; scripts?: Record<string, string> }> {
|
|
339
|
+
try { return JSON.parse(await readFile(path.resolve("package.json"), "utf8")); }
|
|
340
|
+
catch { return {}; }
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
async function runShell(command: string): Promise<void> {
|
|
344
|
+
await new Promise<void>((resolve, reject) => {
|
|
345
|
+
const child = spawn(command, { shell: true, stdio: "inherit", env: process.env });
|
|
346
|
+
child.on("error", reject);
|
|
347
|
+
child.on("exit", (code) => code === 0 ? resolve() : reject(new Error(`Build command exited with code ${code}`)));
|
|
348
|
+
});
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
function resolvePlatforms(value: string, config: PlayPorterConfig): PublicPlatform[] {
|
|
352
|
+
const enabled = (["discord", "telegram", "reddit"] as const).filter((platform) => config.platforms[platform]?.enabled);
|
|
353
|
+
if (value === "all") return enabled;
|
|
354
|
+
if (!enabled.includes(value as PublicPlatform)) throw new Error(`Platform '${value}' is not enabled.`);
|
|
355
|
+
return [value as PublicPlatform];
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
async function generateHostedPackage(
|
|
359
|
+
platform: "discord" | "telegram",
|
|
360
|
+
config: PlayPorterConfig,
|
|
361
|
+
destination: string,
|
|
362
|
+
report: ValidationReportData,
|
|
363
|
+
): Promise<void> {
|
|
364
|
+
const appDir = path.join(destination, "app");
|
|
365
|
+
await fs.copy(path.resolve(config.build.output), appDir, { filter: sourceFilter });
|
|
366
|
+
await injectRuntime(appDir, platform, config);
|
|
367
|
+
await writeJson(path.join(destination, "playporter.deployment.json"), {
|
|
368
|
+
schemaVersion: 1,
|
|
369
|
+
platform,
|
|
370
|
+
game: config.game,
|
|
371
|
+
bundleHash: report.bundleHash,
|
|
372
|
+
generatedAt: new Date().toISOString(),
|
|
373
|
+
sourceCodeIncluded: false,
|
|
374
|
+
});
|
|
375
|
+
if (platform === "discord") {
|
|
376
|
+
await writeJson(path.join(destination, "discord.activity.json"), {
|
|
377
|
+
clientId: config.platforms.discord?.clientId,
|
|
378
|
+
root: "app/index.html",
|
|
379
|
+
urlMappings: config.platforms.discord?.urlMappings ?? [],
|
|
380
|
+
requiredScopes: ["identify"],
|
|
381
|
+
});
|
|
382
|
+
await writeFile(path.join(destination, "README_DEPLOY.md"), discordReadme(config), "utf8");
|
|
383
|
+
} else {
|
|
384
|
+
await writeJson(path.join(destination, "telegram.mini-app.json"), {
|
|
385
|
+
botUsername: config.platforms.telegram?.botUsername,
|
|
386
|
+
root: "app/index.html",
|
|
387
|
+
});
|
|
388
|
+
await writeFile(path.join(destination, "README_DEPLOY.md"), telegramReadme(config), "utf8");
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
async function generateRedditPackage(config: PlayPorterConfig, destination: string, report: ValidationReportData): Promise<void> {
|
|
393
|
+
const clientDir = path.join(destination, "src/client");
|
|
394
|
+
const serverDir = path.join(destination, "src/server");
|
|
395
|
+
await fs.ensureDir(serverDir);
|
|
396
|
+
await fs.copy(path.resolve(config.build.output), clientDir, { filter: sourceFilter });
|
|
397
|
+
await injectRuntime(clientDir, "reddit", config);
|
|
398
|
+
const apiHost = new URL(config.api.baseUrl).hostname;
|
|
399
|
+
const devvitJson = {
|
|
400
|
+
$schema: "https://developers.reddit.com/schema/config-file.v1.json",
|
|
401
|
+
name: config.platforms.reddit?.appName ?? redditName(config.game.slug),
|
|
402
|
+
post: { dir: "dist/client", entrypoints: { default: { entry: "index.html", height: "tall" } } },
|
|
403
|
+
server: { dir: "dist/server", entry: "index.cjs" },
|
|
404
|
+
permissions: {
|
|
405
|
+
reddit: { enable: true },
|
|
406
|
+
http: { enable: true, domains: [apiHost] },
|
|
407
|
+
},
|
|
408
|
+
settings: {
|
|
409
|
+
global: {
|
|
410
|
+
playporterDeploySecret: { type: "string", label: "PlayPorter deploy secret", isSecret: true },
|
|
411
|
+
},
|
|
412
|
+
},
|
|
413
|
+
menu: {
|
|
414
|
+
items: [{
|
|
415
|
+
label: "Create PlayPorter game post",
|
|
416
|
+
description: `Create a ${config.game.name} interactive post`,
|
|
417
|
+
location: "subreddit",
|
|
418
|
+
forUserType: "moderator",
|
|
419
|
+
endpoint: "/internal/menu/create-post",
|
|
420
|
+
}],
|
|
421
|
+
},
|
|
422
|
+
scripts: { dev: "vite build --watch", build: "vite build" },
|
|
423
|
+
dev: config.platforms.reddit?.subreddit ? { subreddit: config.platforms.reddit.subreddit } : undefined,
|
|
424
|
+
};
|
|
425
|
+
await writeJson(path.join(destination, "devvit.json"), devvitJson);
|
|
426
|
+
await writeJson(path.join(destination, "package.json"), {
|
|
427
|
+
name: `${config.game.slug}-reddit`,
|
|
428
|
+
version: config.game.version,
|
|
429
|
+
private: true,
|
|
430
|
+
type: "module",
|
|
431
|
+
scripts: {
|
|
432
|
+
build: "vite build",
|
|
433
|
+
dev: "devvit playtest",
|
|
434
|
+
launch: "devvit upload",
|
|
435
|
+
"settings:set": "devvit settings set playporterDeploySecret",
|
|
436
|
+
},
|
|
437
|
+
dependencies: { "@devvit/web": "^0.13.4", express: "^5.1.0" },
|
|
438
|
+
devDependencies: {
|
|
439
|
+
"@devvit/start": "^0.13.4",
|
|
440
|
+
"@types/express": "^5.0.3",
|
|
441
|
+
devvit: "^0.13.4",
|
|
442
|
+
typescript: "^5.9.3",
|
|
443
|
+
vite: "^7.1.12",
|
|
444
|
+
},
|
|
445
|
+
});
|
|
446
|
+
await writeFile(path.join(destination, "vite.config.ts"), `import { defineConfig } from "vite";\nimport { devvit } from "@devvit/start/vite";\nexport default defineConfig({ plugins: [devvit()] });\n`, "utf8");
|
|
447
|
+
await writeFile(path.join(serverDir, "index.ts"), redditServerSource(config), "utf8");
|
|
448
|
+
await writeJson(path.join(destination, "playporter.deployment.json"), {
|
|
449
|
+
schemaVersion: 1,
|
|
450
|
+
platform: "reddit",
|
|
451
|
+
game: config.game,
|
|
452
|
+
bundleHash: report.bundleHash,
|
|
453
|
+
generatedAt: new Date().toISOString(),
|
|
454
|
+
sourceCodeIncluded: false,
|
|
455
|
+
});
|
|
456
|
+
await writeFile(path.join(destination, "README_DEPLOY.md"), redditReadme(config), "utf8");
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
async function injectRuntime(appDir: string, platform: PublicPlatform, config: PlayPorterConfig): Promise<void> {
|
|
460
|
+
const indexPath = path.join(appDir, "index.html");
|
|
461
|
+
if (!(await fs.pathExists(indexPath))) throw new Error(`Missing ${indexPath}`);
|
|
462
|
+
const runtime = `window.__PLAYPORTER_RUNTIME__=${JSON.stringify({
|
|
463
|
+
platform,
|
|
464
|
+
apiBaseUrl: config.api.baseUrl,
|
|
465
|
+
environment: "production",
|
|
466
|
+
gameSlug: config.game.slug,
|
|
467
|
+
gameVersion: config.game.version,
|
|
468
|
+
discordClientId: config.platforms.discord?.clientId ?? null,
|
|
469
|
+
})};\n`;
|
|
470
|
+
await writeFile(path.join(appDir, "playporter-runtime.js"), runtime, "utf8");
|
|
471
|
+
let html = await readFile(indexPath, "utf8");
|
|
472
|
+
if (!html.includes("playporter-runtime.js")) {
|
|
473
|
+
const tag = '<script src="./playporter-runtime.js"></script>';
|
|
474
|
+
html = html.includes("</head>") ? html.replace("</head>", `${tag}\n</head>`) : `${tag}\n${html}`;
|
|
475
|
+
await writeFile(indexPath, html, "utf8");
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
async function uploadReport(config: PlayPorterConfig, report: ValidationReportData): Promise<void> {
|
|
480
|
+
const projectKey = process.env[config.api.projectKeyEnv];
|
|
481
|
+
if (!projectKey) throw new Error(`Environment variable ${config.api.projectKeyEnv} is required for --upload.`);
|
|
482
|
+
const response = await fetch(`${config.api.baseUrl.replace(/\/$/, "")}/api/v1/validator/reports`, {
|
|
483
|
+
method: "POST",
|
|
484
|
+
headers: { "content-type": "application/json", "x-playporter-game-key": projectKey },
|
|
485
|
+
body: JSON.stringify({
|
|
486
|
+
bundleHash: report.bundleHash,
|
|
487
|
+
version: config.game.version,
|
|
488
|
+
passed: report.passed,
|
|
489
|
+
totalBytes: report.totalBytes,
|
|
490
|
+
fileCount: report.fileCount,
|
|
491
|
+
platforms: report.platforms,
|
|
492
|
+
issues: report.issues,
|
|
493
|
+
}),
|
|
494
|
+
});
|
|
495
|
+
if (!response.ok) throw new Error(`Report upload failed (${response.status}): ${await response.text()}`);
|
|
496
|
+
console.log("Validation report uploaded to PlayPorter Studio.");
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
async function createSignedArtifact(
|
|
500
|
+
config: PlayPorterConfig,
|
|
501
|
+
report: ValidationReportData,
|
|
502
|
+
artifactPath: string,
|
|
503
|
+
): Promise<{ manifest: ArtifactManifest; signature: ArtifactSignature }> {
|
|
504
|
+
const bundleRoot = path.resolve(config.build.output);
|
|
505
|
+
const files = await collectArtifactFiles(bundleRoot);
|
|
506
|
+
const manifest: ArtifactManifest = {
|
|
507
|
+
schemaVersion: 1,
|
|
508
|
+
artifactId: createHash("sha256").update(`${config.game.slug}:${config.game.version}:${report.bundleHash}`).digest("hex").slice(0, 24),
|
|
509
|
+
game: config.game,
|
|
510
|
+
createdAt: new Date().toISOString(),
|
|
511
|
+
builder: {
|
|
512
|
+
cliVersion: program.version() ?? "0.2.0",
|
|
513
|
+
nodeVersion: process.version,
|
|
514
|
+
platform: process.platform,
|
|
515
|
+
},
|
|
516
|
+
bundle: {
|
|
517
|
+
root: "build",
|
|
518
|
+
entry: "build/index.html",
|
|
519
|
+
totalBytes: report.totalBytes,
|
|
520
|
+
fileCount: report.fileCount,
|
|
521
|
+
sha256: report.bundleHash,
|
|
522
|
+
},
|
|
523
|
+
validation: {
|
|
524
|
+
passed: report.passed,
|
|
525
|
+
platforms: report.platforms,
|
|
526
|
+
issueCount: report.issues.length,
|
|
527
|
+
},
|
|
528
|
+
claims: {
|
|
529
|
+
sourceCodeIncluded: false,
|
|
530
|
+
localCompilation: true,
|
|
531
|
+
studioControlledRelease: true,
|
|
532
|
+
},
|
|
533
|
+
files,
|
|
534
|
+
};
|
|
535
|
+
const manifestJson = `${JSON.stringify(manifest, null, 2)}\n`;
|
|
536
|
+
const manifestSha256 = createHash("sha256").update(manifestJson).digest("hex");
|
|
537
|
+
const { privateKeyPem, publicKeyPem } = await ensureArtifactKeyPair(config.game.slug);
|
|
538
|
+
const signature: ArtifactSignature = {
|
|
539
|
+
algorithm: "ed25519",
|
|
540
|
+
publicKeyPem,
|
|
541
|
+
signatureBase64: signDetached(null, Buffer.from(manifestJson, "utf8"), privateKeyPem).toString("base64"),
|
|
542
|
+
signedAt: new Date().toISOString(),
|
|
543
|
+
manifestSha256,
|
|
544
|
+
};
|
|
545
|
+
|
|
546
|
+
await fs.ensureDir(path.dirname(artifactPath));
|
|
547
|
+
await new Promise<void>((resolve, reject) => {
|
|
548
|
+
const output = createWriteStream(artifactPath);
|
|
549
|
+
const archive = archiver("zip", { zlib: { level: 9 } });
|
|
550
|
+
output.on("close", resolve);
|
|
551
|
+
output.on("error", reject);
|
|
552
|
+
archive.on("error", reject);
|
|
553
|
+
archive.pipe(output);
|
|
554
|
+
archive.directory(bundleRoot, "build");
|
|
555
|
+
archive.append(manifestJson, { name: "playporter-manifest.json" });
|
|
556
|
+
archive.append(`${JSON.stringify(signature, null, 2)}\n`, { name: "integrity-signature.json" });
|
|
557
|
+
archive.append(`${JSON.stringify({ generatedAt: new Date().toISOString(), sourceCodeIncluded: false }, null, 2)}\n`, { name: "assets/release-metadata.json" });
|
|
558
|
+
void archive.finalize();
|
|
559
|
+
});
|
|
560
|
+
|
|
561
|
+
await fs.ensureDir(".playporter");
|
|
562
|
+
await writeJson(path.resolve(".playporter/latest-artifact-manifest.json"), manifest);
|
|
563
|
+
await writeJson(path.resolve(".playporter/latest-artifact-signature.json"), signature);
|
|
564
|
+
return { manifest, signature };
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
async function collectArtifactFiles(bundleRoot: string): Promise<ArtifactFileEntry[]> {
|
|
568
|
+
const entries = await fs.readdir(bundleRoot, { recursive: true } as any) as string[];
|
|
569
|
+
const files: ArtifactFileEntry[] = [];
|
|
570
|
+
for (const entry of entries) {
|
|
571
|
+
const relative = String(entry).replaceAll(path.sep, "/");
|
|
572
|
+
const absolute = path.join(bundleRoot, relative);
|
|
573
|
+
if (!(await fs.stat(absolute)).isFile()) continue;
|
|
574
|
+
const content = await readFile(absolute);
|
|
575
|
+
files.push({
|
|
576
|
+
path: `build/${relative}`,
|
|
577
|
+
bytes: content.byteLength,
|
|
578
|
+
sha256: createHash("sha256").update(content).digest("hex"),
|
|
579
|
+
role: relative === "index.html" ? "entry" : relative.startsWith("assets/") ? "asset" : "runtime",
|
|
580
|
+
});
|
|
581
|
+
}
|
|
582
|
+
return files.sort((left, right) => left.path.localeCompare(right.path));
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
async function ensureArtifactKeyPair(slug: string): Promise<{ privateKeyPem: string; publicKeyPem: string }> {
|
|
586
|
+
const keyDir = path.resolve(".playporter/keys");
|
|
587
|
+
const privateKeyPath = path.join(keyDir, `${slug}.private.pem`);
|
|
588
|
+
const publicKeyPath = path.join(keyDir, `${slug}.public.pem`);
|
|
589
|
+
if (await fs.pathExists(privateKeyPath) && await fs.pathExists(publicKeyPath)) {
|
|
590
|
+
return {
|
|
591
|
+
privateKeyPem: await readFile(privateKeyPath, "utf8"),
|
|
592
|
+
publicKeyPem: await readFile(publicKeyPath, "utf8"),
|
|
593
|
+
};
|
|
594
|
+
}
|
|
595
|
+
await fs.ensureDir(keyDir);
|
|
596
|
+
const generated = generateKeyPairSync("ed25519");
|
|
597
|
+
const privateKeyPem = generated.privateKey.export({ format: "pem", type: "pkcs8" }).toString();
|
|
598
|
+
const publicKeyPem = generated.publicKey.export({ format: "pem", type: "spki" }).toString();
|
|
599
|
+
await writeFile(privateKeyPath, privateKeyPem, "utf8");
|
|
600
|
+
await writeFile(publicKeyPath, publicKeyPem, "utf8");
|
|
601
|
+
return { privateKeyPem, publicKeyPem };
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
function printReport(report: ValidationReportData): void {
|
|
605
|
+
console.log(`\nValidation ${report.passed ? "PASSED" : "FAILED"} · ${report.fileCount} files · ${formatBytes(report.totalBytes)}`);
|
|
606
|
+
if (report.bundleHash) console.log(`Bundle SHA-256: ${report.bundleHash}`);
|
|
607
|
+
for (const item of report.issues) {
|
|
608
|
+
console.log(`[${item.severity.toUpperCase()}] ${item.platform}/${item.code}: ${item.message}${item.file ? ` (${item.file})` : ""}`);
|
|
609
|
+
if (item.remediation) console.log(` Fix: ${item.remediation}`);
|
|
610
|
+
}
|
|
611
|
+
if (report.issues.length === 0) console.log("No compatibility issues detected.");
|
|
612
|
+
console.log("");
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
async function zipDirectory(source: string, destination: string): Promise<void> {
|
|
616
|
+
await new Promise<void>((resolve, reject) => {
|
|
617
|
+
const output = createWriteStream(destination);
|
|
618
|
+
const archive = archiver("zip", { zlib: { level: 9 } });
|
|
619
|
+
output.on("close", resolve);
|
|
620
|
+
output.on("error", reject);
|
|
621
|
+
archive.on("error", reject);
|
|
622
|
+
archive.pipe(output);
|
|
623
|
+
archive.directory(source, false);
|
|
624
|
+
void archive.finalize();
|
|
625
|
+
});
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
function redditServerSource(config: PlayPorterConfig): string {
|
|
629
|
+
return `import { createHmac, randomUUID } from "node:crypto";
|
|
630
|
+
import express from "express";
|
|
631
|
+
import { createServer, context, getServerPort, reddit, settings } from "@devvit/web/server";
|
|
632
|
+
|
|
633
|
+
const app = express();
|
|
634
|
+
app.use(express.json());
|
|
635
|
+
|
|
636
|
+
app.get("/api/playporter-session", async (req, res) => {
|
|
637
|
+
const secret = await settings.get<string>("playporterDeploySecret");
|
|
638
|
+
if (!secret) return res.status(503).json({ error: "PlayPorter deploy secret is not configured" });
|
|
639
|
+
const cookies = Object.fromEntries(String(req.headers.cookie ?? "").split(";").map((part) => part.trim().split("=")).filter(([key]) => key));
|
|
640
|
+
const isGuest = !context.userId;
|
|
641
|
+
const guestId = cookies.pp_guest || randomUUID();
|
|
642
|
+
if (isGuest && !cookies.pp_guest) res.setHeader("set-cookie", "pp_guest=" + guestId + "; Path=/; HttpOnly; SameSite=Lax; Max-Age=2592000");
|
|
643
|
+
const payload = {
|
|
644
|
+
userId: context.userId || "reddit_guest_" + guestId,
|
|
645
|
+
subreddit: context.subredditName,
|
|
646
|
+
postId: context.postId,
|
|
647
|
+
issuedAt: Date.now(),
|
|
648
|
+
isGuest,
|
|
649
|
+
};
|
|
650
|
+
const encoded = Buffer.from(JSON.stringify(payload)).toString("base64url");
|
|
651
|
+
const signature = createHmac("sha256", secret).update(encoded).digest("base64url");
|
|
652
|
+
return res.json({ assertion: encoded + "." + signature, context: { subreddit: context.subredditName, postId: context.postId, isGuest } });
|
|
653
|
+
});
|
|
654
|
+
|
|
655
|
+
app.post("/internal/menu/create-post", async (_req, res) => {
|
|
656
|
+
try {
|
|
657
|
+
const post = await reddit.submitCustomPost({
|
|
658
|
+
title: ${JSON.stringify(config.platforms.reddit?.postTitle ?? config.game.name)},
|
|
659
|
+
entry: "default",
|
|
660
|
+
textFallback: { text: ${JSON.stringify(`Play ${config.game.name}. Open this post in the current Reddit app or website for the interactive version.`)} },
|
|
661
|
+
});
|
|
662
|
+
return res.json({ showToast: "Created " + post.id });
|
|
663
|
+
} catch (error) {
|
|
664
|
+
console.error(error);
|
|
665
|
+
return res.status(400).json({ showToast: "Failed to create the PlayPorter post" });
|
|
666
|
+
}
|
|
667
|
+
});
|
|
668
|
+
|
|
669
|
+
const server = createServer(app);
|
|
670
|
+
server.on("error", (error) => console.error(error));
|
|
671
|
+
server.listen(getServerPort());
|
|
672
|
+
`;
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
function bootstrapExample(): string {
|
|
676
|
+
return `import { PlayPorterClient } from "playporter";
|
|
677
|
+
|
|
678
|
+
declare global {
|
|
679
|
+
interface Window {
|
|
680
|
+
__PLAYPORTER_RUNTIME__?: {
|
|
681
|
+
apiBaseUrl: string;
|
|
682
|
+
platform: "discord" | "telegram" | "reddit" | "web";
|
|
683
|
+
gameVersion: string;
|
|
684
|
+
discordClientId?: string | null;
|
|
685
|
+
};
|
|
686
|
+
}
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
const runtime = window.__PLAYPORTER_RUNTIME__;
|
|
690
|
+
export const playporter = await PlayPorterClient.initialize({
|
|
691
|
+
apiBaseUrl: runtime?.apiBaseUrl ?? import.meta.env.VITE_PLAYPORTER_URL,
|
|
692
|
+
projectKey: import.meta.env.VITE_PLAYPORTER_PROJECT_KEY,
|
|
693
|
+
platform: runtime?.platform ?? "auto",
|
|
694
|
+
appVersion: runtime?.gameVersion ?? "0.1.0",
|
|
695
|
+
discordClientId: runtime?.discordClientId ?? import.meta.env.VITE_DISCORD_CLIENT_ID,
|
|
696
|
+
});
|
|
697
|
+
|
|
698
|
+
const player = await playporter.identity.getPlayer();
|
|
699
|
+
const config = await playporter.liveops.getConfig();
|
|
700
|
+
await playporter.passport.unlockAchievement("first_session");
|
|
701
|
+
playporter.analytics.track("game_loaded", { playerId: player.id, revision: config.revision });
|
|
702
|
+
`;
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
function discordReadme(config: PlayPorterConfig): string {
|
|
706
|
+
return `# Discord Activity package\n\n1. Host the \`app/\` directory on HTTPS.\n2. In Discord Developer Portal, enable Activities.\n3. Map \`/\` to the hosted app and map the PlayPorter API domain.\n4. Configure Client ID and Client Secret in PlayPorter Studio.\n5. Test desktop, mobile and web.\n\nGame: ${config.game.name} ${config.game.version}\n`;
|
|
707
|
+
}
|
|
708
|
+
function telegramReadme(config: PlayPorterConfig): string {
|
|
709
|
+
return `# Telegram Mini App package\n\n1. Host the \`app/\` directory on HTTPS.\n2. Configure the Mini App URL with BotFather.\n3. Store the bot token in PlayPorter Studio.\n4. Launch only from Telegram so initData can be validated server-side.\n\nGame: ${config.game.name} ${config.game.version}\n`;
|
|
710
|
+
}
|
|
711
|
+
function redditReadme(config: PlayPorterConfig): string {
|
|
712
|
+
return `# Reddit Devvit Web package\n\nRequires Node.js 22.2+.\n\n\`npm install\`\n\`npm run settings:set\` # use the same deploy secret stored in PlayPorter Studio\n\`npm run dev\`\n\nUse the subreddit moderator menu item **Create PlayPorter game post**. When ready, run \`npm run launch\`.\n\nGame: ${config.game.name} ${config.game.version}\n`;
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
function sourceFilter(source: string): boolean {
|
|
716
|
+
const base = path.basename(source);
|
|
717
|
+
return ![".git", "node_modules", ".env", ".env.local", "src"].includes(base) && !source.endsWith(".ts") && !source.endsWith(".tsx");
|
|
718
|
+
}
|
|
719
|
+
function slugify(value: string): string { return value.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "").slice(0, 48) || "my-game"; }
|
|
720
|
+
function redditName(value: string): string { const slug = slugify(value).slice(0, 16); return /^[a-z]/.test(slug) ? slug.padEnd(3, "x") : `g-${slug}`.slice(0, 16); }
|
|
721
|
+
function formatBytes(bytes: number): string { return bytes < 1024 * 1024 ? `${(bytes / 1024).toFixed(1)} KiB` : `${(bytes / 1024 / 1024).toFixed(1)} MiB`; }
|
|
722
|
+
async function writeJson(file: string, data: unknown): Promise<void> { await fs.ensureDir(path.dirname(file)); await writeFile(file, `${JSON.stringify(data, null, 2)}\n`, "utf8"); }
|
|
723
|
+
function yamlHeader(): string { return "# PlayPorter vendor-neutral control plane config\n# https://playporter.dev/docs/config\n\n"; }
|