pressship 0.1.11 → 0.1.13
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/.claude/skills/wordpress-plugin-publish/SKILL.md +58 -5
- package/README.md +13 -0
- package/assets/web/app.js +6425 -0
- package/assets/web/harness-sdk-icon-mono.svg +33 -0
- package/assets/web/harness-sdk-icon.svg +120 -0
- package/assets/web/index.html +392 -0
- package/assets/web/style.css +6454 -0
- package/dist/cli.js +14 -0
- package/dist/cli.js.map +1 -1
- package/dist/plugin/demo.d.ts +75 -4
- package/dist/plugin/demo.js +520 -31
- package/dist/plugin/demo.js.map +1 -1
- package/dist/utils/paths.d.ts +5 -0
- package/dist/utils/paths.js +15 -0
- package/dist/utils/paths.js.map +1 -1
- package/dist/web/ai-assistance.d.ts +70 -0
- package/dist/web/ai-assistance.js +262 -0
- package/dist/web/ai-assistance.js.map +1 -0
- package/dist/web/index.d.ts +9 -0
- package/dist/web/index.js +34 -0
- package/dist/web/index.js.map +1 -0
- package/dist/web/jobs.d.ts +33 -0
- package/dist/web/jobs.js +155 -0
- package/dist/web/jobs.js.map +1 -0
- package/dist/web/open-url.d.ts +1 -0
- package/dist/web/open-url.js +13 -0
- package/dist/web/open-url.js.map +1 -0
- package/dist/web/plugin-check-state.d.ts +47 -0
- package/dist/web/plugin-check-state.js +107 -0
- package/dist/web/plugin-check-state.js.map +1 -0
- package/dist/web/plugin-check.d.ts +11 -0
- package/dist/web/plugin-check.js +124 -0
- package/dist/web/plugin-check.js.map +1 -0
- package/dist/web/ports.d.ts +2 -0
- package/dist/web/ports.js +38 -0
- package/dist/web/ports.js.map +1 -0
- package/dist/web/registry.d.ts +49 -0
- package/dist/web/registry.js +106 -0
- package/dist/web/registry.js.map +1 -0
- package/dist/web/release.d.ts +87 -0
- package/dist/web/release.js +413 -0
- package/dist/web/release.js.map +1 -0
- package/dist/web/server.d.ts +24 -0
- package/dist/web/server.js +1419 -0
- package/dist/web/server.js.map +1 -0
- package/dist/web/settings.d.ts +36 -0
- package/dist/web/settings.js +72 -0
- package/dist/web/settings.js.map +1 -0
- package/dist/web/version-state.d.ts +28 -0
- package/dist/web/version-state.js +114 -0
- package/dist/web/version-state.js.map +1 -0
- package/dist/wordpress-org/publish.d.ts +1 -0
- package/dist/wordpress-org/publish.js +4 -2
- package/dist/wordpress-org/publish.js.map +1 -1
- package/dist/wordpress-org/submit.d.ts +1 -0
- package/dist/wordpress-org/submit.js +7 -5
- package/dist/wordpress-org/submit.js.map +1 -1
- package/package.json +8 -4
|
@@ -0,0 +1,1419 @@
|
|
|
1
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
2
|
+
import { createReadStream } from "node:fs";
|
|
3
|
+
import { cp, mkdir, mkdtemp, readFile, rm, stat, unlink, writeFile } from "node:fs/promises";
|
|
4
|
+
import { createServer } from "node:http";
|
|
5
|
+
import { createRequire } from "node:module";
|
|
6
|
+
import { tmpdir } from "node:os";
|
|
7
|
+
import { fileURLToPath } from "node:url";
|
|
8
|
+
import { spawn } from "node:child_process";
|
|
9
|
+
import path from "node:path";
|
|
10
|
+
import { execa } from "execa";
|
|
11
|
+
import fg from "fast-glob";
|
|
12
|
+
import { z } from "zod";
|
|
13
|
+
import { hasSavedSession } from "../auth/session.js";
|
|
14
|
+
import { getWordPressOrgAccount } from "../auth/whoami.js";
|
|
15
|
+
import { createPluginPack, summarizePackResult, validatePluginPack } from "../package/pack.js";
|
|
16
|
+
import { discoverPluginProject, resolvePluginProjectPath } from "../plugin/discover.js";
|
|
17
|
+
import { assertDemoLaunchPlanSupported, createDemoLaunchPlan, prepareDemoRuntime, publicDemoLaunchPlan, resetPlaygroundSite } from "../plugin/demo.js";
|
|
18
|
+
import { fetchHostedPluginInfo, getPluginInfo } from "../plugin/info.js";
|
|
19
|
+
import { getPluginList } from "../plugin/list.js";
|
|
20
|
+
import { bumpVersion, updatePluginHeaderVersion, updateReadmeStableTag } from "../plugin/version.js";
|
|
21
|
+
import { checkoutOrUpdatePlugin, resolveCheckoutPath } from "../svn/get.js";
|
|
22
|
+
import { getSavedSvnPassword, getSvnPasswordUrl } from "../svn/credentials.js";
|
|
23
|
+
import { createReleaseCommandPlan, svnRepositoryExists } from "../svn/release.js";
|
|
24
|
+
import { publish } from "../wordpress-org/publish.js";
|
|
25
|
+
import { fetchPluginStates, matchesPluginState } from "../wordpress-org/state.js";
|
|
26
|
+
import { runPluginCheck } from "../checks/plugin-check.js";
|
|
27
|
+
import { hasBlockingFindings } from "../checks/summary.js";
|
|
28
|
+
import { ensureCacheDir, getConfigDir } from "../utils/paths.js";
|
|
29
|
+
import { addLocalPluginPath, getLocalPlugin, listLocalPlugins, removeLocalPlugin } from "./registry.js";
|
|
30
|
+
import { WebJobManager } from "./jobs.js";
|
|
31
|
+
import { getVersionState } from "./version-state.js";
|
|
32
|
+
import { createReleaseTag, deleteReleaseTag, isValidExplicitVersion, listReleaseTags, ReleaseError, ReleaseSwitchConflictError, switchReleaseTag } from "./release.js";
|
|
33
|
+
import { isPortAvailable, resolveFreePort } from "./ports.js";
|
|
34
|
+
import { readWebSettings, webSettingsSchema, writeWebSettings } from "./settings.js";
|
|
35
|
+
import { createAiAssistantPrompt, detectAiAssistance, describeAiAssistantRun, getAiAssistantHarnesses, runAiAssistant, isInstalledAiAssistantId } from "./ai-assistance.js";
|
|
36
|
+
import { addStudioPluginCheckLineHints, normalizeStudioPluginCheckFindings, summarizeStudioPluginCheckFindings } from "./plugin-check.js";
|
|
37
|
+
import { readStudioPluginCheckState, removeStudioPluginCheckFindingsForFiles, removeStudioPluginCheckState, writeStudioPluginCheckState } from "./plugin-check-state.js";
|
|
38
|
+
const nodeRequire = createRequire(import.meta.url);
|
|
39
|
+
const mutationMethods = new Set(["POST", "PUT", "PATCH", "DELETE"]);
|
|
40
|
+
const addLocalPluginSchema = z.object({ path: z.string().min(1) });
|
|
41
|
+
const bumpVersionSchema = z.object({ bump: z.enum(["patch", "minor", "major"]) });
|
|
42
|
+
const setVersionSchema = z.object({ version: z.string().min(1) });
|
|
43
|
+
const createSvnTagSchema = z.object({ name: z.string().min(1) });
|
|
44
|
+
const switchSvnTagSchema = z.object({ conflictResolution: z.enum(["override", "revert"]).optional() });
|
|
45
|
+
const writeStudioFileSchema = z.object({
|
|
46
|
+
path: z.string().min(1),
|
|
47
|
+
content: z.string()
|
|
48
|
+
});
|
|
49
|
+
const studioAiChangeSchema = z.object({
|
|
50
|
+
path: z.string().min(1),
|
|
51
|
+
status: z.enum(["created", "modified", "deleted"]),
|
|
52
|
+
beforeContent: z.string().optional(),
|
|
53
|
+
afterContent: z.string().optional()
|
|
54
|
+
});
|
|
55
|
+
const installedAiAssistantSchema = z.custom((value) => typeof value === "string" && isInstalledAiAssistantId(value), { message: "Unknown AI assistant." });
|
|
56
|
+
const jobSchema = z.discriminatedUnion("type", [
|
|
57
|
+
z.object({
|
|
58
|
+
type: z.literal("clone"),
|
|
59
|
+
slug: z.string().min(1),
|
|
60
|
+
destination: z.string().optional()
|
|
61
|
+
}),
|
|
62
|
+
z.object({
|
|
63
|
+
type: z.literal("play"),
|
|
64
|
+
scope: z.enum(["remote", "local"]),
|
|
65
|
+
id: z.string().min(1),
|
|
66
|
+
wpVersion: z.string().min(1).max(20).optional()
|
|
67
|
+
}),
|
|
68
|
+
z.object({
|
|
69
|
+
type: z.literal("check"),
|
|
70
|
+
localId: z.string().min(1)
|
|
71
|
+
}),
|
|
72
|
+
z.object({
|
|
73
|
+
type: z.literal("ai-chat"),
|
|
74
|
+
localId: z.string().min(1),
|
|
75
|
+
prompt: z.string().min(1),
|
|
76
|
+
assistant: installedAiAssistantSchema.optional(),
|
|
77
|
+
selectedFile: z.string().optional()
|
|
78
|
+
}),
|
|
79
|
+
z.object({
|
|
80
|
+
type: z.literal("dry-run-publish"),
|
|
81
|
+
localId: z.string().min(1),
|
|
82
|
+
action: z.enum(["auto", "submit", "release"]).default("auto")
|
|
83
|
+
}),
|
|
84
|
+
z.object({
|
|
85
|
+
type: z.literal("confirm-publish"),
|
|
86
|
+
approvalId: z.string().min(1),
|
|
87
|
+
overview: z.string().optional()
|
|
88
|
+
}),
|
|
89
|
+
z.object({
|
|
90
|
+
type: z.literal("svn-switch"),
|
|
91
|
+
localId: z.string().min(1),
|
|
92
|
+
tag: z.string().min(1),
|
|
93
|
+
conflictResolution: z.enum(["override", "revert"]).optional()
|
|
94
|
+
})
|
|
95
|
+
]);
|
|
96
|
+
export async function startWebServer(options = {}) {
|
|
97
|
+
const host = options.host ?? "127.0.0.1";
|
|
98
|
+
const requestedPort = options.port === undefined ? 9477 : Number(options.port);
|
|
99
|
+
const port = await resolveFreePort(host, requestedPort, options.port !== undefined);
|
|
100
|
+
const token = randomBytes(24).toString("hex");
|
|
101
|
+
const jobs = new WebJobManager();
|
|
102
|
+
const approvals = new Map();
|
|
103
|
+
const playgrounds = new Map();
|
|
104
|
+
const playgroundPortReservations = new Set();
|
|
105
|
+
const staticDir = resolveStaticDir();
|
|
106
|
+
const server = createServer((request, response) => {
|
|
107
|
+
void handleRequest(request, response, {
|
|
108
|
+
token,
|
|
109
|
+
jobs,
|
|
110
|
+
approvals,
|
|
111
|
+
playgrounds,
|
|
112
|
+
playgroundPortReservations,
|
|
113
|
+
staticDir
|
|
114
|
+
});
|
|
115
|
+
});
|
|
116
|
+
await new Promise((resolve, reject) => {
|
|
117
|
+
server.once("error", reject);
|
|
118
|
+
server.listen(port, host, () => {
|
|
119
|
+
server.off("error", reject);
|
|
120
|
+
resolve();
|
|
121
|
+
});
|
|
122
|
+
});
|
|
123
|
+
const address = server.address();
|
|
124
|
+
const boundPort = typeof address === "object" && address ? address.port : port;
|
|
125
|
+
const url = `http://${host}:${boundPort}/`;
|
|
126
|
+
server.on("close", () => {
|
|
127
|
+
jobs.cancelRunningJobs();
|
|
128
|
+
stopPlaygrounds(playgrounds);
|
|
129
|
+
playgroundPortReservations.clear();
|
|
130
|
+
});
|
|
131
|
+
return {
|
|
132
|
+
server,
|
|
133
|
+
url,
|
|
134
|
+
token,
|
|
135
|
+
jobs,
|
|
136
|
+
close: async () => {
|
|
137
|
+
jobs.cancelRunningJobs();
|
|
138
|
+
stopPlaygrounds(playgrounds);
|
|
139
|
+
playgroundPortReservations.clear();
|
|
140
|
+
await new Promise((resolve, reject) => {
|
|
141
|
+
server.close((error) => (error ? reject(error) : resolve()));
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
async function handleRequest(request, response, context) {
|
|
147
|
+
try {
|
|
148
|
+
const url = new URL(request.url ?? "/", "http://localhost");
|
|
149
|
+
if (mutationMethods.has(request.method ?? "GET") && request.headers["x-pressship-token"] !== context.token) {
|
|
150
|
+
sendJson(response, 403, { error: { message: "Missing or invalid Pressship Studio token.", code: "invalid_token" } });
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
if (url.pathname.startsWith("/api/")) {
|
|
154
|
+
await handleApi(request, response, url, context);
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
if (url.pathname.startsWith("/brand/")) {
|
|
158
|
+
await serveBrandAsset(response, url.pathname);
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
if (url.pathname === "/vendor/marked.esm.js") {
|
|
162
|
+
await serveVendorAsset(response, nodeRequire.resolve("marked"));
|
|
163
|
+
return;
|
|
164
|
+
}
|
|
165
|
+
await serveStatic(response, context.staticDir, url.pathname, context.token);
|
|
166
|
+
}
|
|
167
|
+
catch (error) {
|
|
168
|
+
sendJson(response, 500, { error: { message: error instanceof Error ? error.message : String(error) } });
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
async function serveBrandAsset(response, requestPath) {
|
|
172
|
+
const brandDir = path.resolve(resolveStaticDir(), "..");
|
|
173
|
+
const filePath = path.join(brandDir, path.basename(requestPath));
|
|
174
|
+
if (!filePath.startsWith(brandDir)) {
|
|
175
|
+
sendJson(response, 403, { error: { message: "Forbidden." } });
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
178
|
+
response.writeHead(200, { "Content-Type": contentType(filePath) });
|
|
179
|
+
createReadStream(filePath)
|
|
180
|
+
.on("error", () => sendJson(response, 404, { error: { message: "Not found." } }))
|
|
181
|
+
.pipe(response);
|
|
182
|
+
}
|
|
183
|
+
async function serveVendorAsset(response, filePath) {
|
|
184
|
+
response.writeHead(200, {
|
|
185
|
+
"Cache-Control": "no-store",
|
|
186
|
+
"Content-Type": contentType(filePath)
|
|
187
|
+
});
|
|
188
|
+
createReadStream(filePath)
|
|
189
|
+
.on("error", () => sendJson(response, 404, { error: { message: "Not found." } }))
|
|
190
|
+
.pipe(response);
|
|
191
|
+
}
|
|
192
|
+
async function handleApi(request, response, url, context) {
|
|
193
|
+
const method = request.method ?? "GET";
|
|
194
|
+
if (method === "GET" && url.pathname === "/api/bootstrap") {
|
|
195
|
+
const loggedIn = await hasSavedSession();
|
|
196
|
+
const account = loggedIn ? await getWordPressOrgAccount().catch(() => undefined) : undefined;
|
|
197
|
+
const settings = await readWebSettings();
|
|
198
|
+
sendJson(response, 200, {
|
|
199
|
+
token: context.token,
|
|
200
|
+
loggedIn,
|
|
201
|
+
account,
|
|
202
|
+
configDir: getConfigDir(),
|
|
203
|
+
cwd: process.cwd(),
|
|
204
|
+
defaultCheckoutDir: settings.defaultCheckoutDir,
|
|
205
|
+
playgroundPortRange: [settings.playgroundPortStart, settings.playgroundPortEnd],
|
|
206
|
+
settings,
|
|
207
|
+
aiHarnesses: getAiAssistantHarnesses(),
|
|
208
|
+
jobs: context.jobs.list(),
|
|
209
|
+
playgrounds: listPlaygrounds(context.playgrounds)
|
|
210
|
+
});
|
|
211
|
+
return;
|
|
212
|
+
}
|
|
213
|
+
if (method === "GET" && url.pathname === "/api/playgrounds") {
|
|
214
|
+
sendJson(response, 200, { playgrounds: listPlaygrounds(context.playgrounds) });
|
|
215
|
+
return;
|
|
216
|
+
}
|
|
217
|
+
if (method === "GET" && url.pathname === "/api/settings") {
|
|
218
|
+
sendJson(response, 200, await readWebSettings());
|
|
219
|
+
return;
|
|
220
|
+
}
|
|
221
|
+
if (method === "GET" && url.pathname === "/api/ai-assistance") {
|
|
222
|
+
sendJson(response, 200, await detectAiAssistance());
|
|
223
|
+
return;
|
|
224
|
+
}
|
|
225
|
+
if (method === "PUT" && url.pathname === "/api/settings") {
|
|
226
|
+
const body = webSettingsSchema.parse(await readJson(request));
|
|
227
|
+
sendJson(response, 200, await writeWebSettings(body));
|
|
228
|
+
return;
|
|
229
|
+
}
|
|
230
|
+
if (method === "POST" && url.pathname === "/api/select-folder") {
|
|
231
|
+
sendJson(response, 200, { path: await selectFolder() });
|
|
232
|
+
return;
|
|
233
|
+
}
|
|
234
|
+
if (method === "GET" && url.pathname === "/api/plugins/remote") {
|
|
235
|
+
sendJson(response, 200, await getPluginList(undefined, { public: false }));
|
|
236
|
+
return;
|
|
237
|
+
}
|
|
238
|
+
if (method === "GET" && url.pathname === "/api/plugins/local") {
|
|
239
|
+
sendJson(response, 200, { plugins: await listLocalPlugins() });
|
|
240
|
+
return;
|
|
241
|
+
}
|
|
242
|
+
if (method === "POST" && url.pathname === "/api/plugins/local") {
|
|
243
|
+
const body = addLocalPluginSchema.parse(await readJson(request));
|
|
244
|
+
sendJson(response, 200, await addLocalPluginPath(body.path, "manual"));
|
|
245
|
+
return;
|
|
246
|
+
}
|
|
247
|
+
const deleteLocalMatch = url.pathname.match(/^\/api\/plugins\/local\/([^/]+)$/);
|
|
248
|
+
if (method === "DELETE" && deleteLocalMatch) {
|
|
249
|
+
const localId = decodeURIComponent(deleteLocalMatch[1]);
|
|
250
|
+
const removed = await removeLocalPlugin(localId);
|
|
251
|
+
if (removed) {
|
|
252
|
+
await removeStudioPluginCheckState(localId);
|
|
253
|
+
}
|
|
254
|
+
sendJson(response, 200, { removed });
|
|
255
|
+
return;
|
|
256
|
+
}
|
|
257
|
+
const detailMatch = url.pathname.match(/^\/api\/plugins\/(remote|local)\/([^/]+)$/);
|
|
258
|
+
if (method === "GET" && detailMatch) {
|
|
259
|
+
sendJson(response, 200, await readPluginDetail(detailMatch[1], decodeURIComponent(detailMatch[2])));
|
|
260
|
+
return;
|
|
261
|
+
}
|
|
262
|
+
const versionMatch = url.pathname.match(/^\/api\/plugins\/local\/([^/]+)\/version-state$/);
|
|
263
|
+
if (method === "GET" && versionMatch) {
|
|
264
|
+
const plugin = await requireLocalPlugin(decodeURIComponent(versionMatch[1]));
|
|
265
|
+
sendJson(response, 200, await getVersionState(plugin.path));
|
|
266
|
+
return;
|
|
267
|
+
}
|
|
268
|
+
const studioFilesMatch = url.pathname.match(/^\/api\/plugins\/local\/([^/]+)\/files$/);
|
|
269
|
+
if (method === "GET" && studioFilesMatch) {
|
|
270
|
+
const plugin = await requireLocalPlugin(decodeURIComponent(studioFilesMatch[1]));
|
|
271
|
+
sendJson(response, 200, await listStudioFiles(plugin.path));
|
|
272
|
+
return;
|
|
273
|
+
}
|
|
274
|
+
const studioCheckStateMatch = url.pathname.match(/^\/api\/plugins\/local\/([^/]+)\/check-state$/);
|
|
275
|
+
if (method === "GET" && studioCheckStateMatch) {
|
|
276
|
+
const localId = decodeURIComponent(studioCheckStateMatch[1]);
|
|
277
|
+
await requireLocalPlugin(localId);
|
|
278
|
+
sendJson(response, 200, { state: await readStudioPluginCheckState(localId) });
|
|
279
|
+
return;
|
|
280
|
+
}
|
|
281
|
+
const studioFileContentMatch = url.pathname.match(/^\/api\/plugins\/local\/([^/]+)\/files\/content$/);
|
|
282
|
+
if (method === "GET" && studioFileContentMatch) {
|
|
283
|
+
const plugin = await requireLocalPlugin(decodeURIComponent(studioFileContentMatch[1]));
|
|
284
|
+
const relativePath = url.searchParams.get("path") ?? "";
|
|
285
|
+
sendJson(response, 200, await readStudioFile(plugin.path, relativePath));
|
|
286
|
+
return;
|
|
287
|
+
}
|
|
288
|
+
if (method === "PUT" && studioFileContentMatch) {
|
|
289
|
+
const localId = decodeURIComponent(studioFileContentMatch[1]);
|
|
290
|
+
const plugin = await requireLocalPlugin(localId);
|
|
291
|
+
const body = writeStudioFileSchema.parse(await readJson(request));
|
|
292
|
+
const saved = await writeStudioFile(plugin.path, body.path, body.content);
|
|
293
|
+
sendJson(response, 200, {
|
|
294
|
+
...saved,
|
|
295
|
+
checkState: await removeStudioPluginCheckFindingsForFiles(localId, [saved.path])
|
|
296
|
+
});
|
|
297
|
+
return;
|
|
298
|
+
}
|
|
299
|
+
const studioAiChangeApplyMatch = url.pathname.match(/^\/api\/plugins\/local\/([^/]+)\/ai-changes\/apply$/);
|
|
300
|
+
if (method === "POST" && studioAiChangeApplyMatch) {
|
|
301
|
+
const localId = decodeURIComponent(studioAiChangeApplyMatch[1]);
|
|
302
|
+
const plugin = await requireLocalPlugin(localId);
|
|
303
|
+
const body = studioAiChangeSchema.parse(await readJson(request));
|
|
304
|
+
try {
|
|
305
|
+
const applied = await applyStudioAiChange(plugin.path, body);
|
|
306
|
+
const checkState = await removeStudioPluginCheckFindingsForFiles(localId, [applied.path]);
|
|
307
|
+
sendJson(response, 200, {
|
|
308
|
+
...applied,
|
|
309
|
+
files: (await listStudioFiles(plugin.path)).files,
|
|
310
|
+
checkState
|
|
311
|
+
});
|
|
312
|
+
}
|
|
313
|
+
catch (error) {
|
|
314
|
+
if (error instanceof StudioAiChangeConflictError) {
|
|
315
|
+
sendJson(response, 409, { error: { message: error.message, code: "ai_change_conflict" } });
|
|
316
|
+
return;
|
|
317
|
+
}
|
|
318
|
+
throw error;
|
|
319
|
+
}
|
|
320
|
+
return;
|
|
321
|
+
}
|
|
322
|
+
const bumpMatch = url.pathname.match(/^\/api\/plugins\/local\/([^/]+)\/bump-version$/);
|
|
323
|
+
if (method === "POST" && bumpMatch) {
|
|
324
|
+
const body = bumpVersionSchema.parse(await readJson(request));
|
|
325
|
+
const localId = decodeURIComponent(bumpMatch[1]);
|
|
326
|
+
const plugin = await requireLocalPlugin(localId);
|
|
327
|
+
await bumpLocalPluginVersion(plugin.path, body.bump);
|
|
328
|
+
await addLocalPluginPath(plugin.path, plugin.source);
|
|
329
|
+
await removeStudioPluginCheckState(localId);
|
|
330
|
+
sendJson(response, 200, { ...(await getVersionState(plugin.path)), checkState: null });
|
|
331
|
+
return;
|
|
332
|
+
}
|
|
333
|
+
const setVersionMatch = url.pathname.match(/^\/api\/plugins\/local\/([^/]+)\/version$/);
|
|
334
|
+
if (method === "PUT" && setVersionMatch) {
|
|
335
|
+
const body = setVersionSchema.parse(await readJson(request));
|
|
336
|
+
const localId = decodeURIComponent(setVersionMatch[1]);
|
|
337
|
+
const plugin = await requireLocalPlugin(localId);
|
|
338
|
+
const trimmed = body.version.trim();
|
|
339
|
+
if (!isValidExplicitVersion(trimmed)) {
|
|
340
|
+
sendJson(response, 400, {
|
|
341
|
+
error: {
|
|
342
|
+
message: "Version must look like 1, 1.2, 1.2.3, or 1.2.3-beta.",
|
|
343
|
+
code: "invalid_version"
|
|
344
|
+
}
|
|
345
|
+
});
|
|
346
|
+
return;
|
|
347
|
+
}
|
|
348
|
+
try {
|
|
349
|
+
await setLocalPluginVersion(plugin.path, trimmed);
|
|
350
|
+
await addLocalPluginPath(plugin.path, plugin.source);
|
|
351
|
+
await removeStudioPluginCheckState(localId);
|
|
352
|
+
sendJson(response, 200, { ...(await getVersionState(plugin.path)), checkState: null });
|
|
353
|
+
}
|
|
354
|
+
catch (error) {
|
|
355
|
+
sendJson(response, 400, {
|
|
356
|
+
error: { message: error instanceof Error ? error.message : String(error), code: "version_update_failed" }
|
|
357
|
+
});
|
|
358
|
+
}
|
|
359
|
+
return;
|
|
360
|
+
}
|
|
361
|
+
const svnTagsListMatch = url.pathname.match(/^\/api\/plugins\/local\/([^/]+)\/svn-tags$/);
|
|
362
|
+
if (method === "GET" && svnTagsListMatch) {
|
|
363
|
+
const plugin = await requireLocalPlugin(decodeURIComponent(svnTagsListMatch[1]));
|
|
364
|
+
try {
|
|
365
|
+
sendJson(response, 200, await listReleaseTags(plugin.path));
|
|
366
|
+
}
|
|
367
|
+
catch (error) {
|
|
368
|
+
sendReleaseError(response, error);
|
|
369
|
+
}
|
|
370
|
+
return;
|
|
371
|
+
}
|
|
372
|
+
if (method === "POST" && svnTagsListMatch) {
|
|
373
|
+
const plugin = await requireLocalPlugin(decodeURIComponent(svnTagsListMatch[1]));
|
|
374
|
+
const body = createSvnTagSchema.parse(await readJson(request));
|
|
375
|
+
try {
|
|
376
|
+
const tag = await createReleaseTag(plugin.path, body.name);
|
|
377
|
+
sendJson(response, 201, { tag, list: await listReleaseTags(plugin.path) });
|
|
378
|
+
}
|
|
379
|
+
catch (error) {
|
|
380
|
+
sendReleaseError(response, error);
|
|
381
|
+
}
|
|
382
|
+
return;
|
|
383
|
+
}
|
|
384
|
+
const svnTagOpsMatch = url.pathname.match(/^\/api\/plugins\/local\/([^/]+)\/svn-tags\/([^/]+)$/);
|
|
385
|
+
if (method === "DELETE" && svnTagOpsMatch) {
|
|
386
|
+
const plugin = await requireLocalPlugin(decodeURIComponent(svnTagOpsMatch[1]));
|
|
387
|
+
const tagName = decodeURIComponent(svnTagOpsMatch[2]);
|
|
388
|
+
try {
|
|
389
|
+
await deleteReleaseTag(plugin.path, tagName);
|
|
390
|
+
sendJson(response, 200, { deleted: tagName, list: await listReleaseTags(plugin.path) });
|
|
391
|
+
}
|
|
392
|
+
catch (error) {
|
|
393
|
+
sendReleaseError(response, error);
|
|
394
|
+
}
|
|
395
|
+
return;
|
|
396
|
+
}
|
|
397
|
+
const svnTagSwitchMatch = url.pathname.match(/^\/api\/plugins\/local\/([^/]+)\/svn-tags\/([^/]+)\/switch$/);
|
|
398
|
+
if (method === "POST" && svnTagSwitchMatch) {
|
|
399
|
+
const localId = decodeURIComponent(svnTagSwitchMatch[1]);
|
|
400
|
+
const tagName = decodeURIComponent(svnTagSwitchMatch[2]);
|
|
401
|
+
const body = switchSvnTagSchema.parse(await readJson(request));
|
|
402
|
+
await requireLocalPlugin(localId);
|
|
403
|
+
const job = context.jobs.create("svn-switch", `Switch to ${tagName}`, (jobContext) => switchReleaseTagJob(localId, tagName, body.conflictResolution, jobContext));
|
|
404
|
+
sendJson(response, 202, job);
|
|
405
|
+
return;
|
|
406
|
+
}
|
|
407
|
+
if (method === "GET" && url.pathname === "/api/release-board") {
|
|
408
|
+
sendJson(response, 200, await buildReleaseBoard());
|
|
409
|
+
return;
|
|
410
|
+
}
|
|
411
|
+
if (method === "POST" && url.pathname === "/api/jobs") {
|
|
412
|
+
const body = jobSchema.parse(await readJson(request));
|
|
413
|
+
const job = createWebJob(body, context.jobs, context.approvals, context.playgrounds, context.playgroundPortReservations);
|
|
414
|
+
sendJson(response, 202, job);
|
|
415
|
+
return;
|
|
416
|
+
}
|
|
417
|
+
const stopPlaygroundMatch = url.pathname.match(/^\/api\/playgrounds\/([^/]+)$/);
|
|
418
|
+
if (method === "DELETE" && stopPlaygroundMatch) {
|
|
419
|
+
sendJson(response, 200, {
|
|
420
|
+
stopped: stopPlayground(context.playgrounds, decodeURIComponent(stopPlaygroundMatch[1]))
|
|
421
|
+
});
|
|
422
|
+
return;
|
|
423
|
+
}
|
|
424
|
+
const jobEventsMatch = url.pathname.match(/^\/api\/jobs\/([^/]+)\/events$/);
|
|
425
|
+
if (method === "GET" && jobEventsMatch) {
|
|
426
|
+
if (url.searchParams.get("token") !== context.token) {
|
|
427
|
+
sendJson(response, 403, { error: { message: "Missing or invalid Pressship Studio token.", code: "invalid_token" } });
|
|
428
|
+
return;
|
|
429
|
+
}
|
|
430
|
+
streamJobEvents(response, context.jobs, decodeURIComponent(jobEventsMatch[1]));
|
|
431
|
+
return;
|
|
432
|
+
}
|
|
433
|
+
const cancelJobMatch = url.pathname.match(/^\/api\/jobs\/([^/]+)\/cancel$/);
|
|
434
|
+
if (method === "POST" && cancelJobMatch) {
|
|
435
|
+
sendJson(response, 200, { cancelled: context.jobs.cancel(decodeURIComponent(cancelJobMatch[1])) });
|
|
436
|
+
return;
|
|
437
|
+
}
|
|
438
|
+
sendJson(response, 404, { error: { message: "Not found.", code: "not_found" } });
|
|
439
|
+
}
|
|
440
|
+
function createWebJob(input, jobs, approvals, playgrounds, playgroundPortReservations) {
|
|
441
|
+
if (input.type === "clone") {
|
|
442
|
+
return jobs.create("clone", `Clone/update ${input.slug}`, (context) => clonePluginJob(input, context));
|
|
443
|
+
}
|
|
444
|
+
if (input.type === "play") {
|
|
445
|
+
return jobs.create("play", `Start Playground for ${input.id}`, (context) => playPluginJob(input, playgrounds, playgroundPortReservations, context));
|
|
446
|
+
}
|
|
447
|
+
if (input.type === "check") {
|
|
448
|
+
return jobs.create("check", "Plugin Check", (context) => pluginCheckJob(input.localId, context));
|
|
449
|
+
}
|
|
450
|
+
if (input.type === "ai-chat") {
|
|
451
|
+
return jobs.create("ai-chat", "AI Assistance", (context) => aiChatJob(input, context));
|
|
452
|
+
}
|
|
453
|
+
if (input.type === "dry-run-publish") {
|
|
454
|
+
return jobs.create("dry-run-publish", "Dry-run publish", (context) => dryRunPublishJob(input.localId, input.action, approvals, context));
|
|
455
|
+
}
|
|
456
|
+
if (input.type === "svn-switch") {
|
|
457
|
+
return jobs.create("svn-switch", `Switch to ${input.tag}`, (context) => switchReleaseTagJob(input.localId, input.tag, input.conflictResolution, context));
|
|
458
|
+
}
|
|
459
|
+
return jobs.create("confirm-publish", "Confirmed publish", (context) => confirmPublishJob(input.approvalId, input.overview, approvals, context));
|
|
460
|
+
}
|
|
461
|
+
async function clonePluginJob(input, context) {
|
|
462
|
+
const slug = input.slug.replace(/^\/+|\/+$/g, "");
|
|
463
|
+
const settings = await readWebSettings();
|
|
464
|
+
const requestedDestination = input.destination ?? path.resolve(settings.defaultCheckoutDir, slug);
|
|
465
|
+
const destination = resolveCheckoutPath(slug, requestedDestination);
|
|
466
|
+
await mkdir(path.dirname(destination), { recursive: true });
|
|
467
|
+
context.status(`Preparing SVN checkout at ${destination}`);
|
|
468
|
+
const result = await checkoutOrUpdatePlugin(slug, destination, {
|
|
469
|
+
installSvn: false,
|
|
470
|
+
interactive: false,
|
|
471
|
+
quiet: true
|
|
472
|
+
});
|
|
473
|
+
context.log(`${result.action === "checkout" ? "Checked out" : "Updated"} ${result.slug}.`, result);
|
|
474
|
+
const plugin = await addLocalPluginPath(result.path, "clone");
|
|
475
|
+
return { result, plugin };
|
|
476
|
+
}
|
|
477
|
+
async function playPluginJob(input, playgrounds, playgroundPortReservations, context) {
|
|
478
|
+
const target = input.scope === "local" ? (await requireLocalPlugin(input.id)).path : input.id;
|
|
479
|
+
const settings = await readWebSettings();
|
|
480
|
+
const port = await reservePlaygroundPort(settings, playgrounds, playgroundPortReservations);
|
|
481
|
+
let child;
|
|
482
|
+
let started = false;
|
|
483
|
+
try {
|
|
484
|
+
const wpVersion = input.wpVersion === "latest" ? undefined : input.wpVersion;
|
|
485
|
+
const plan = await createDemoLaunchPlan(target, {
|
|
486
|
+
port: String(port),
|
|
487
|
+
skipBrowser: true,
|
|
488
|
+
reset: false,
|
|
489
|
+
wp: wpVersion,
|
|
490
|
+
database: settings.playgroundDatabaseMode,
|
|
491
|
+
mysqlHost: settings.playgroundMysqlHost,
|
|
492
|
+
mysqlPort: settings.playgroundMysqlPort,
|
|
493
|
+
mysqlUser: settings.playgroundMysqlUser,
|
|
494
|
+
mysqlPassword: settings.playgroundMysqlPassword,
|
|
495
|
+
mysqlDatabasePrefix: settings.playgroundMysqlDatabasePrefix
|
|
496
|
+
});
|
|
497
|
+
if (!plan.url) {
|
|
498
|
+
throw new Error("Could not determine Playground URL.");
|
|
499
|
+
}
|
|
500
|
+
assertDemoLaunchPlanSupported(plan);
|
|
501
|
+
context.status(`Resetting Playground site at ${plan.siteDir}`);
|
|
502
|
+
await resetPlaygroundSite(plan.siteDir);
|
|
503
|
+
if (plan.database.mode === "mysql") {
|
|
504
|
+
context.status(`Preparing MySQL database ${plan.database.database} at ${plan.database.host}:${plan.database.port}`);
|
|
505
|
+
await prepareDemoRuntime(plan, { resetDatabase: true });
|
|
506
|
+
if (plan.database.server === "managed-docker") {
|
|
507
|
+
context.status(`Using managed MariaDB container at ${plan.database.host}:${plan.database.port} for legacy Playground`);
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
context.status(`Starting Playground for ${plan.name} on ${plan.url} ` +
|
|
511
|
+
`(WordPress ${plan.wpVersion ?? "latest"}, PHP ${plan.phpVersion ?? "latest"})`);
|
|
512
|
+
const spawned = spawn(plan.command, plan.args, { cwd: plan.cwd, stdio: ["ignore", "pipe", "pipe"] });
|
|
513
|
+
child = spawned;
|
|
514
|
+
context.registerCancel(() => spawned.kill("SIGTERM"));
|
|
515
|
+
spawned.stdout.on("data", (chunk) => context.log(chunk.toString()));
|
|
516
|
+
spawned.stderr.on("data", (chunk) => context.log(chunk.toString()));
|
|
517
|
+
let removeStartupListeners = () => { };
|
|
518
|
+
const exitBeforeReady = new Promise((_resolve, reject) => {
|
|
519
|
+
const onError = (error) => reject(error);
|
|
520
|
+
const onExit = (code, signal) => {
|
|
521
|
+
reject(new Error(`Playground exited before it was ready (${signal ?? code ?? "unknown"}).`));
|
|
522
|
+
};
|
|
523
|
+
spawned.once("error", onError);
|
|
524
|
+
spawned.once("exit", onExit);
|
|
525
|
+
removeStartupListeners = () => {
|
|
526
|
+
spawned.off("error", onError);
|
|
527
|
+
spawned.off("exit", onExit);
|
|
528
|
+
};
|
|
529
|
+
});
|
|
530
|
+
try {
|
|
531
|
+
await Promise.race([waitForPlaygroundReady(plan.url, context.signal), exitBeforeReady]);
|
|
532
|
+
}
|
|
533
|
+
finally {
|
|
534
|
+
removeStartupListeners();
|
|
535
|
+
}
|
|
536
|
+
const instance = {
|
|
537
|
+
id: createPlaygroundId(plan.slug, plan.url),
|
|
538
|
+
name: plan.name,
|
|
539
|
+
slug: plan.slug,
|
|
540
|
+
source: plan.source,
|
|
541
|
+
url: plan.url,
|
|
542
|
+
startedAt: new Date().toISOString(),
|
|
543
|
+
pid: spawned.pid,
|
|
544
|
+
child: spawned
|
|
545
|
+
};
|
|
546
|
+
playgrounds.set(instance.id, instance);
|
|
547
|
+
spawned.once("exit", () => {
|
|
548
|
+
playgrounds.delete(instance.id);
|
|
549
|
+
playgroundPortReservations.delete(port);
|
|
550
|
+
});
|
|
551
|
+
started = true;
|
|
552
|
+
context.status(`Playground is ready at ${plan.url}`);
|
|
553
|
+
return {
|
|
554
|
+
url: plan.url,
|
|
555
|
+
urls: playgroundUrls(plan.url),
|
|
556
|
+
credentials: {
|
|
557
|
+
username: "admin",
|
|
558
|
+
password: "password"
|
|
559
|
+
},
|
|
560
|
+
playground: publicPlayground(instance),
|
|
561
|
+
plan: publicDemoLaunchPlan(plan)
|
|
562
|
+
};
|
|
563
|
+
}
|
|
564
|
+
catch (error) {
|
|
565
|
+
if (!started) {
|
|
566
|
+
child?.kill("SIGTERM");
|
|
567
|
+
playgroundPortReservations.delete(port);
|
|
568
|
+
}
|
|
569
|
+
throw error;
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
async function pluginCheckJob(localId, context) {
|
|
573
|
+
const local = await requireLocalPlugin(localId);
|
|
574
|
+
const source = resolvePluginProjectPath(local.path);
|
|
575
|
+
const project = await discoverPluginProject(source.rootDir);
|
|
576
|
+
context.status(`Running WordPress.org Plugin Check for ${project.headers.pluginName}.`);
|
|
577
|
+
const result = await runPluginCheck(source.rootDir, { mode: "new" });
|
|
578
|
+
const findings = await addStudioPluginCheckLineHints(normalizeStudioPluginCheckFindings(result.findings, source.rootDir, project.slug), source.rootDir);
|
|
579
|
+
const summary = summarizeStudioPluginCheckFindings(findings);
|
|
580
|
+
const checkedAt = new Date().toISOString();
|
|
581
|
+
const persisted = await writeStudioPluginCheckState({
|
|
582
|
+
pluginId: local.id,
|
|
583
|
+
pluginPath: local.path,
|
|
584
|
+
slug: local.slug,
|
|
585
|
+
name: local.name,
|
|
586
|
+
skipped: result.skipped,
|
|
587
|
+
available: result.available,
|
|
588
|
+
findings,
|
|
589
|
+
summary,
|
|
590
|
+
checkedAt
|
|
591
|
+
});
|
|
592
|
+
context.log(`Plugin Check finished: ${summary.error} errors, ${summary.warning} warnings, ${summary.info} info.`);
|
|
593
|
+
return {
|
|
594
|
+
plugin: {
|
|
595
|
+
id: local.id,
|
|
596
|
+
name: local.name,
|
|
597
|
+
slug: local.slug,
|
|
598
|
+
path: local.path
|
|
599
|
+
},
|
|
600
|
+
skipped: result.skipped,
|
|
601
|
+
available: result.available,
|
|
602
|
+
findings,
|
|
603
|
+
summary,
|
|
604
|
+
checkedAt: persisted.checkedAt,
|
|
605
|
+
rawOutput: result.rawOutput
|
|
606
|
+
};
|
|
607
|
+
}
|
|
608
|
+
async function aiChatJob(input, context) {
|
|
609
|
+
const local = await requireLocalPlugin(input.localId);
|
|
610
|
+
const source = resolvePluginProjectPath(local.path);
|
|
611
|
+
const settings = await readWebSettings();
|
|
612
|
+
const assistant = input.assistant ?? settings.aiAssistant;
|
|
613
|
+
if (assistant === "none") {
|
|
614
|
+
throw new Error("Choose an AI assistant in Settings before starting chat.");
|
|
615
|
+
}
|
|
616
|
+
const selectedAssistant = assistant;
|
|
617
|
+
const before = await snapshotStudioFileContents(source.rootDir);
|
|
618
|
+
const workspace = await createStudioAiPreviewWorkspace(source.rootDir);
|
|
619
|
+
const pluginCheck = await readStudioPluginCheckState(local.id);
|
|
620
|
+
const prompt = createAiAssistantPrompt({
|
|
621
|
+
pluginPath: workspace.path,
|
|
622
|
+
selectedFile: input.selectedFile,
|
|
623
|
+
userPrompt: input.prompt,
|
|
624
|
+
pluginCheck
|
|
625
|
+
});
|
|
626
|
+
context.status(`Starting ${selectedAssistant} in a review workspace.`);
|
|
627
|
+
context.status(pluginCheck?.summary
|
|
628
|
+
? `Included Plugin Check context: ${pluginCheck.summary.error} errors, ${pluginCheck.summary.warning} warnings.`
|
|
629
|
+
: "Included Plugin Check context: no saved check result.");
|
|
630
|
+
try {
|
|
631
|
+
const run = await runAiAssistant(selectedAssistant, prompt, {
|
|
632
|
+
cwd: workspace.path,
|
|
633
|
+
signal: context.signal,
|
|
634
|
+
onEvent(event) {
|
|
635
|
+
if (event.type === "chunk" && event.text) {
|
|
636
|
+
context.log(event.text);
|
|
637
|
+
}
|
|
638
|
+
}
|
|
639
|
+
});
|
|
640
|
+
const after = await snapshotStudioFileContents(workspace.path);
|
|
641
|
+
const changedFiles = diffStudioFileContents(before, after);
|
|
642
|
+
if (changedFiles.length) {
|
|
643
|
+
context.log(`AI proposed ${changedFiles.length} patch${changedFiles.length === 1 ? "" : "es"}.`, {
|
|
644
|
+
proposedChanges: changedFiles.map((file) => ({
|
|
645
|
+
path: file.path,
|
|
646
|
+
status: file.status,
|
|
647
|
+
additions: file.additions,
|
|
648
|
+
deletions: file.deletions
|
|
649
|
+
}))
|
|
650
|
+
});
|
|
651
|
+
}
|
|
652
|
+
return {
|
|
653
|
+
assistant: run.provider,
|
|
654
|
+
command: describeAiAssistantRun(run),
|
|
655
|
+
exitCode: run.exitCode,
|
|
656
|
+
timedOut: run.timedOut,
|
|
657
|
+
aborted: run.aborted,
|
|
658
|
+
plugin: {
|
|
659
|
+
id: local.id,
|
|
660
|
+
name: local.name,
|
|
661
|
+
slug: local.slug,
|
|
662
|
+
path: source.rootDir
|
|
663
|
+
},
|
|
664
|
+
selectedFile: input.selectedFile,
|
|
665
|
+
changedFiles
|
|
666
|
+
};
|
|
667
|
+
}
|
|
668
|
+
finally {
|
|
669
|
+
await rm(workspace.root, { recursive: true, force: true });
|
|
670
|
+
}
|
|
671
|
+
}
|
|
672
|
+
async function dryRunPublishJob(localId, requestedAction, approvals, context) {
|
|
673
|
+
const local = await requireLocalPlugin(localId);
|
|
674
|
+
const source = resolvePluginProjectPath(local.path);
|
|
675
|
+
const project = await discoverPluginProject(source.rootDir);
|
|
676
|
+
context.status(`Discovered ${project.headers.pluginName}.`);
|
|
677
|
+
const route = await detectPublishRoute(source.inputDir, source.rootDir, project.slug, project.headers.pluginName, requestedAction);
|
|
678
|
+
context.log(`Publish target: ${route.action} (${route.reason})`);
|
|
679
|
+
const versionState = await getVersionState(source.rootDir);
|
|
680
|
+
if (route.action === "release" && versionState.releaseBlocked) {
|
|
681
|
+
context.log("Release is blocked by version state.", versionState);
|
|
682
|
+
}
|
|
683
|
+
context.status("Validating package.");
|
|
684
|
+
const validation = await validatePluginPack(project, {});
|
|
685
|
+
const validationBlocked = hasBlockingFindings(validation.readmeFindings) || hasBlockingFindings(validation.pluginCheckFindings);
|
|
686
|
+
const cacheDir = path.join(await ensureCacheDir(), "studio-packages");
|
|
687
|
+
await mkdir(cacheDir, { recursive: true, mode: 0o700 });
|
|
688
|
+
const pack = summarizePackResult(await createPluginPack(source.rootDir, { outputDir: cacheDir }), validation);
|
|
689
|
+
const releasePlan = route.action === "release"
|
|
690
|
+
? createReleaseCommandPlan(project.slug, path.resolve(source.svnRootDir ?? path.join(process.cwd(), ".pressship-svn", project.slug)), project.version ?? "unknown", `Release ${project.slug} ${project.version ?? "unknown"}`, await inferWordPressOrgUsername())
|
|
691
|
+
: undefined;
|
|
692
|
+
const canConfirm = !validationBlocked && !(route.action === "release" && versionState.releaseBlocked);
|
|
693
|
+
const approval = canConfirm
|
|
694
|
+
? createApproval(approvals, {
|
|
695
|
+
localId,
|
|
696
|
+
pluginPath: source.inputDir,
|
|
697
|
+
action: route.action,
|
|
698
|
+
version: project.version
|
|
699
|
+
})
|
|
700
|
+
: undefined;
|
|
701
|
+
return {
|
|
702
|
+
route,
|
|
703
|
+
versionState,
|
|
704
|
+
validation,
|
|
705
|
+
validationBlocked,
|
|
706
|
+
package: pack,
|
|
707
|
+
releasePlan,
|
|
708
|
+
approvalId: approval?.id,
|
|
709
|
+
canConfirm
|
|
710
|
+
};
|
|
711
|
+
}
|
|
712
|
+
async function confirmPublishJob(approvalId, overview, approvals, context) {
|
|
713
|
+
const approval = approvals.get(approvalId);
|
|
714
|
+
if (!approval || Date.now() - approval.createdAt > 20 * 60 * 1000) {
|
|
715
|
+
throw new Error("This publish approval is missing or expired. Run a fresh dry-run first.");
|
|
716
|
+
}
|
|
717
|
+
const project = await discoverPluginProject(resolvePluginProjectPath(approval.pluginPath).rootDir);
|
|
718
|
+
if (approval.version !== project.version) {
|
|
719
|
+
throw new Error("The plugin version changed after the dry-run. Run a fresh dry-run before publishing.");
|
|
720
|
+
}
|
|
721
|
+
if (approval.action === "release") {
|
|
722
|
+
await assertWebReleaseCredentials(project.slug);
|
|
723
|
+
}
|
|
724
|
+
context.status(`Running confirmed ${approval.action}.`);
|
|
725
|
+
await publish(approval.pluginPath, {
|
|
726
|
+
dryRun: false,
|
|
727
|
+
verify: true,
|
|
728
|
+
yes: true,
|
|
729
|
+
submit: approval.action === "submit",
|
|
730
|
+
release: approval.action === "release",
|
|
731
|
+
overview: overview ?? project.headers.description ?? ""
|
|
732
|
+
});
|
|
733
|
+
approvals.delete(approvalId);
|
|
734
|
+
return { action: approval.action, slug: project.slug, version: project.version };
|
|
735
|
+
}
|
|
736
|
+
async function readPluginDetail(scope, id) {
|
|
737
|
+
if (scope === "local") {
|
|
738
|
+
const plugin = await requireLocalPlugin(id);
|
|
739
|
+
const info = await getPluginInfo(plugin.path);
|
|
740
|
+
return {
|
|
741
|
+
plugin,
|
|
742
|
+
info,
|
|
743
|
+
readme: info.source === "local" && info.readmePath ? await readTextFile(info.readmePath) : undefined
|
|
744
|
+
};
|
|
745
|
+
}
|
|
746
|
+
const info = await fetchHostedPluginInfo(id);
|
|
747
|
+
return {
|
|
748
|
+
info,
|
|
749
|
+
readme: await fetchHostedReadme(id).catch(() => undefined)
|
|
750
|
+
};
|
|
751
|
+
}
|
|
752
|
+
async function listStudioFiles(pluginPath) {
|
|
753
|
+
const root = path.resolve(pluginPath);
|
|
754
|
+
const files = await fg([
|
|
755
|
+
"**/*.{php,js,jsx,ts,tsx,css,scss,sass,html,htm,json,md,txt,xml,yml,yaml,po,pot,ini,sh}",
|
|
756
|
+
"composer.json",
|
|
757
|
+
"package.json",
|
|
758
|
+
"readme.txt"
|
|
759
|
+
], {
|
|
760
|
+
cwd: root,
|
|
761
|
+
onlyFiles: true,
|
|
762
|
+
dot: false,
|
|
763
|
+
unique: true,
|
|
764
|
+
ignore: [
|
|
765
|
+
"**/.git/**",
|
|
766
|
+
"**/.svn/**",
|
|
767
|
+
"**/node_modules/**",
|
|
768
|
+
"**/vendor/**",
|
|
769
|
+
"**/build/**",
|
|
770
|
+
"**/dist/**",
|
|
771
|
+
"**/playground/**",
|
|
772
|
+
"**/.wordpress-playground/**"
|
|
773
|
+
]
|
|
774
|
+
});
|
|
775
|
+
const entries = await Promise.all(files.sort((a, b) => a.localeCompare(b)).map(async (relativePath) => {
|
|
776
|
+
const fileStats = await stat(path.join(root, relativePath));
|
|
777
|
+
return {
|
|
778
|
+
path: relativePath,
|
|
779
|
+
name: path.basename(relativePath),
|
|
780
|
+
directory: path.dirname(relativePath) === "." ? "" : path.dirname(relativePath),
|
|
781
|
+
size: fileStats.size
|
|
782
|
+
};
|
|
783
|
+
}));
|
|
784
|
+
return { files: entries.filter((entry) => entry.size <= 1_000_000) };
|
|
785
|
+
}
|
|
786
|
+
async function readStudioFile(pluginPath, relativePath) {
|
|
787
|
+
const filePath = await resolveStudioFilePath(pluginPath, relativePath);
|
|
788
|
+
return {
|
|
789
|
+
path: normalizeStudioRelativePath(relativePath),
|
|
790
|
+
content: await readFile(filePath, "utf8")
|
|
791
|
+
};
|
|
792
|
+
}
|
|
793
|
+
async function writeStudioFile(pluginPath, relativePath, content) {
|
|
794
|
+
const filePath = await resolveStudioFilePath(pluginPath, relativePath);
|
|
795
|
+
await writeFile(filePath, content, "utf8");
|
|
796
|
+
const fileStats = await stat(filePath);
|
|
797
|
+
return {
|
|
798
|
+
path: normalizeStudioRelativePath(relativePath),
|
|
799
|
+
size: fileStats.size,
|
|
800
|
+
savedAt: new Date().toISOString()
|
|
801
|
+
};
|
|
802
|
+
}
|
|
803
|
+
async function applyStudioAiChange(pluginPath, change) {
|
|
804
|
+
const normalizedPath = normalizeStudioRelativePath(change.path);
|
|
805
|
+
const filePath = resolveStudioWritableFilePath(pluginPath, normalizedPath);
|
|
806
|
+
const currentContent = await readStudioFileIfExists(filePath);
|
|
807
|
+
if (change.status === "created") {
|
|
808
|
+
if (currentContent !== undefined) {
|
|
809
|
+
throw new StudioAiChangeConflictError(`${normalizedPath} already exists. Reload the file before applying this patch.`);
|
|
810
|
+
}
|
|
811
|
+
if (change.afterContent === undefined) {
|
|
812
|
+
throw new Error("Created AI patches must include replacement content.");
|
|
813
|
+
}
|
|
814
|
+
await mkdir(path.dirname(filePath), { recursive: true });
|
|
815
|
+
await writeFile(filePath, change.afterContent, "utf8");
|
|
816
|
+
const fileStats = await stat(filePath);
|
|
817
|
+
return {
|
|
818
|
+
path: normalizedPath,
|
|
819
|
+
status: change.status,
|
|
820
|
+
size: fileStats.size,
|
|
821
|
+
appliedAt: new Date().toISOString()
|
|
822
|
+
};
|
|
823
|
+
}
|
|
824
|
+
if (currentContent === undefined) {
|
|
825
|
+
throw new StudioAiChangeConflictError(`${normalizedPath} no longer exists. Reload the file before applying this patch.`);
|
|
826
|
+
}
|
|
827
|
+
if (change.beforeContent === undefined || currentContent !== change.beforeContent) {
|
|
828
|
+
throw new StudioAiChangeConflictError(`${normalizedPath} changed since the AI patch was created. Reload before applying.`);
|
|
829
|
+
}
|
|
830
|
+
if (change.status === "deleted") {
|
|
831
|
+
await unlink(filePath);
|
|
832
|
+
return {
|
|
833
|
+
path: normalizedPath,
|
|
834
|
+
status: change.status,
|
|
835
|
+
appliedAt: new Date().toISOString()
|
|
836
|
+
};
|
|
837
|
+
}
|
|
838
|
+
if (change.afterContent === undefined) {
|
|
839
|
+
throw new Error("Modified AI patches must include replacement content.");
|
|
840
|
+
}
|
|
841
|
+
await writeFile(filePath, change.afterContent, "utf8");
|
|
842
|
+
const fileStats = await stat(filePath);
|
|
843
|
+
return {
|
|
844
|
+
path: normalizedPath,
|
|
845
|
+
status: change.status,
|
|
846
|
+
size: fileStats.size,
|
|
847
|
+
appliedAt: new Date().toISOString()
|
|
848
|
+
};
|
|
849
|
+
}
|
|
850
|
+
function resolveStudioWritableFilePath(pluginPath, relativePath) {
|
|
851
|
+
const root = path.resolve(pluginPath);
|
|
852
|
+
if (!relativePath) {
|
|
853
|
+
throw new Error("Choose a file first.");
|
|
854
|
+
}
|
|
855
|
+
const filePath = path.resolve(root, relativePath);
|
|
856
|
+
if (filePath !== root && !filePath.startsWith(`${root}${path.sep}`)) {
|
|
857
|
+
throw new Error("File is outside the plugin directory.");
|
|
858
|
+
}
|
|
859
|
+
return filePath;
|
|
860
|
+
}
|
|
861
|
+
async function readStudioFileIfExists(filePath) {
|
|
862
|
+
try {
|
|
863
|
+
const fileStats = await stat(filePath);
|
|
864
|
+
if (!fileStats.isFile()) {
|
|
865
|
+
throw new StudioAiChangeConflictError("The patch target is not a file.");
|
|
866
|
+
}
|
|
867
|
+
return await readFile(filePath, "utf8");
|
|
868
|
+
}
|
|
869
|
+
catch (error) {
|
|
870
|
+
if (error.code === "ENOENT") {
|
|
871
|
+
return undefined;
|
|
872
|
+
}
|
|
873
|
+
throw error;
|
|
874
|
+
}
|
|
875
|
+
}
|
|
876
|
+
async function resolveStudioFilePath(pluginPath, relativePath) {
|
|
877
|
+
const root = path.resolve(pluginPath);
|
|
878
|
+
const normalized = normalizeStudioRelativePath(relativePath);
|
|
879
|
+
if (!normalized) {
|
|
880
|
+
throw new Error("Choose a file first.");
|
|
881
|
+
}
|
|
882
|
+
const filePath = path.resolve(root, normalized);
|
|
883
|
+
if (filePath !== root && !filePath.startsWith(`${root}${path.sep}`)) {
|
|
884
|
+
throw new Error("File is outside the plugin directory.");
|
|
885
|
+
}
|
|
886
|
+
const fileStats = await stat(filePath);
|
|
887
|
+
if (!fileStats.isFile()) {
|
|
888
|
+
throw new Error("Studio can only open files.");
|
|
889
|
+
}
|
|
890
|
+
return filePath;
|
|
891
|
+
}
|
|
892
|
+
function normalizeStudioRelativePath(relativePath) {
|
|
893
|
+
return relativePath.replace(/\\/g, "/").replace(/^\/+/, "").split("/").filter(Boolean).join("/");
|
|
894
|
+
}
|
|
895
|
+
async function selectFolder() {
|
|
896
|
+
if (process.platform === "darwin") {
|
|
897
|
+
const result = await execa("osascript", [
|
|
898
|
+
"-e",
|
|
899
|
+
'POSIX path of (choose folder with prompt "Choose a WordPress plugin folder")'
|
|
900
|
+
]);
|
|
901
|
+
return result.stdout.trim().replace(/\/$/, "");
|
|
902
|
+
}
|
|
903
|
+
if (process.platform === "win32") {
|
|
904
|
+
const script = [
|
|
905
|
+
"Add-Type -AssemblyName System.Windows.Forms",
|
|
906
|
+
"$dialog = New-Object System.Windows.Forms.FolderBrowserDialog",
|
|
907
|
+
'$dialog.Description = "Choose a WordPress plugin folder"',
|
|
908
|
+
"if ($dialog.ShowDialog() -eq [System.Windows.Forms.DialogResult]::OK) { $dialog.SelectedPath } else { exit 1 }"
|
|
909
|
+
].join("; ");
|
|
910
|
+
const result = await execa("powershell", ["-NoProfile", "-Command", script]);
|
|
911
|
+
return result.stdout.trim();
|
|
912
|
+
}
|
|
913
|
+
try {
|
|
914
|
+
const result = await execa("zenity", ["--file-selection", "--directory", "--title=Choose a WordPress plugin folder"]);
|
|
915
|
+
return result.stdout.trim();
|
|
916
|
+
}
|
|
917
|
+
catch {
|
|
918
|
+
const result = await execa("kdialog", ["--getexistingdirectory", process.cwd(), "Choose a WordPress plugin folder"]);
|
|
919
|
+
return result.stdout.trim();
|
|
920
|
+
}
|
|
921
|
+
}
|
|
922
|
+
async function reservePlaygroundPort(settings, playgrounds, playgroundPortReservations) {
|
|
923
|
+
for (let port = settings.playgroundPortStart; port <= settings.playgroundPortEnd; port += 1) {
|
|
924
|
+
if (isPlaygroundPortReserved(port, playgrounds, playgroundPortReservations)) {
|
|
925
|
+
continue;
|
|
926
|
+
}
|
|
927
|
+
playgroundPortReservations.add(port);
|
|
928
|
+
if (await isPortAvailable("127.0.0.1", port)) {
|
|
929
|
+
return port;
|
|
930
|
+
}
|
|
931
|
+
playgroundPortReservations.delete(port);
|
|
932
|
+
}
|
|
933
|
+
throw new Error(`No available Playground port found between ${settings.playgroundPortStart} and ${settings.playgroundPortEnd}.`);
|
|
934
|
+
}
|
|
935
|
+
function isPlaygroundPortReserved(port, playgrounds, playgroundPortReservations) {
|
|
936
|
+
return (playgroundPortReservations.has(port) ||
|
|
937
|
+
Array.from(playgrounds.values()).some((playground) => playgroundPort(playground.url) === port));
|
|
938
|
+
}
|
|
939
|
+
function playgroundPort(url) {
|
|
940
|
+
try {
|
|
941
|
+
const parsed = new URL(url);
|
|
942
|
+
if (parsed.port) {
|
|
943
|
+
return Number(parsed.port);
|
|
944
|
+
}
|
|
945
|
+
if (parsed.protocol === "http:") {
|
|
946
|
+
return 80;
|
|
947
|
+
}
|
|
948
|
+
if (parsed.protocol === "https:") {
|
|
949
|
+
return 443;
|
|
950
|
+
}
|
|
951
|
+
}
|
|
952
|
+
catch {
|
|
953
|
+
return undefined;
|
|
954
|
+
}
|
|
955
|
+
return undefined;
|
|
956
|
+
}
|
|
957
|
+
async function waitForPlaygroundReady(url, signal) {
|
|
958
|
+
const deadline = Date.now() + 120_000;
|
|
959
|
+
let lastError;
|
|
960
|
+
while (Date.now() < deadline) {
|
|
961
|
+
if (signal.aborted) {
|
|
962
|
+
throw new Error("Playground start was cancelled.");
|
|
963
|
+
}
|
|
964
|
+
try {
|
|
965
|
+
const response = await fetch(url, { signal });
|
|
966
|
+
if (response.status < 500) {
|
|
967
|
+
return;
|
|
968
|
+
}
|
|
969
|
+
lastError = new Error(`HTTP ${response.status}`);
|
|
970
|
+
}
|
|
971
|
+
catch (error) {
|
|
972
|
+
lastError = error;
|
|
973
|
+
}
|
|
974
|
+
await new Promise((resolve) => setTimeout(resolve, 750));
|
|
975
|
+
}
|
|
976
|
+
throw new Error(`Timed out waiting for Playground at ${url}. ${lastError instanceof Error ? lastError.message : String(lastError ?? "")}`);
|
|
977
|
+
}
|
|
978
|
+
function listPlaygrounds(playgrounds) {
|
|
979
|
+
return Array.from(playgrounds.values()).map(publicPlayground);
|
|
980
|
+
}
|
|
981
|
+
function publicPlayground(playground) {
|
|
982
|
+
return {
|
|
983
|
+
id: playground.id,
|
|
984
|
+
name: playground.name,
|
|
985
|
+
slug: playground.slug,
|
|
986
|
+
source: playground.source,
|
|
987
|
+
url: playground.url,
|
|
988
|
+
startedAt: playground.startedAt,
|
|
989
|
+
pid: playground.pid
|
|
990
|
+
};
|
|
991
|
+
}
|
|
992
|
+
function stopPlaygrounds(playgrounds) {
|
|
993
|
+
for (const playground of playgrounds.values()) {
|
|
994
|
+
playground.child.kill("SIGTERM");
|
|
995
|
+
}
|
|
996
|
+
playgrounds.clear();
|
|
997
|
+
}
|
|
998
|
+
function stopPlayground(playgrounds, id) {
|
|
999
|
+
const playground = playgrounds.get(id);
|
|
1000
|
+
if (!playground) {
|
|
1001
|
+
return false;
|
|
1002
|
+
}
|
|
1003
|
+
playground.child.kill("SIGTERM");
|
|
1004
|
+
playgrounds.delete(id);
|
|
1005
|
+
return true;
|
|
1006
|
+
}
|
|
1007
|
+
function createPlaygroundId(slug, url) {
|
|
1008
|
+
return createHash("sha256").update(`${slug}:${url}:${Date.now()}`).digest("hex").slice(0, 16);
|
|
1009
|
+
}
|
|
1010
|
+
function playgroundUrls(baseUrl) {
|
|
1011
|
+
const home = new URL("/", baseUrl);
|
|
1012
|
+
const admin = new URL("/wp-admin/", baseUrl);
|
|
1013
|
+
admin.searchParams.set("pressship_auto_login", "1");
|
|
1014
|
+
return {
|
|
1015
|
+
home: home.toString().replace(/\/$/, ""),
|
|
1016
|
+
admin: admin.toString()
|
|
1017
|
+
};
|
|
1018
|
+
}
|
|
1019
|
+
async function bumpLocalPluginVersion(pluginPath, bump) {
|
|
1020
|
+
const project = await discoverPluginProject(pluginPath);
|
|
1021
|
+
if (!project.version) {
|
|
1022
|
+
throw new Error("Could not find a Version header in the main plugin file.");
|
|
1023
|
+
}
|
|
1024
|
+
const nextVersion = bumpVersion(project.version, bump);
|
|
1025
|
+
await updatePluginHeaderVersion(project.mainFile, nextVersion);
|
|
1026
|
+
if (project.readmePath) {
|
|
1027
|
+
await updateReadmeStableTag(project.readmePath, nextVersion);
|
|
1028
|
+
}
|
|
1029
|
+
return nextVersion;
|
|
1030
|
+
}
|
|
1031
|
+
async function setLocalPluginVersion(pluginPath, nextVersion) {
|
|
1032
|
+
const project = await discoverPluginProject(pluginPath);
|
|
1033
|
+
await updatePluginHeaderVersion(project.mainFile, nextVersion);
|
|
1034
|
+
if (project.readmePath) {
|
|
1035
|
+
await updateReadmeStableTag(project.readmePath, nextVersion);
|
|
1036
|
+
}
|
|
1037
|
+
return nextVersion;
|
|
1038
|
+
}
|
|
1039
|
+
async function switchReleaseTagJob(localId, tagName, conflictResolution, context) {
|
|
1040
|
+
const plugin = await requireLocalPlugin(localId);
|
|
1041
|
+
context.status(`Switching ${plugin.slug} to ${tagName}.`);
|
|
1042
|
+
try {
|
|
1043
|
+
const result = await switchReleaseTag(plugin.path, tagName, context, { conflictResolution });
|
|
1044
|
+
await removeStudioPluginCheckState(localId);
|
|
1045
|
+
return { ...result, slug: plugin.slug, checkState: null };
|
|
1046
|
+
}
|
|
1047
|
+
catch (error) {
|
|
1048
|
+
if (error instanceof ReleaseSwitchConflictError) {
|
|
1049
|
+
context.status("SVN switch needs a conflict resolution choice.");
|
|
1050
|
+
return {
|
|
1051
|
+
conflict: true,
|
|
1052
|
+
code: error.code,
|
|
1053
|
+
message: error.message,
|
|
1054
|
+
output: error.output,
|
|
1055
|
+
ref: error.ref,
|
|
1056
|
+
slug: plugin.slug,
|
|
1057
|
+
workingCopy: error.workingCopy
|
|
1058
|
+
};
|
|
1059
|
+
}
|
|
1060
|
+
throw error;
|
|
1061
|
+
}
|
|
1062
|
+
}
|
|
1063
|
+
async function buildReleaseBoard() {
|
|
1064
|
+
const plugins = await listLocalPlugins();
|
|
1065
|
+
const entries = await Promise.all(plugins.map(async (plugin) => {
|
|
1066
|
+
if (!plugin.exists || plugin.error) {
|
|
1067
|
+
return {
|
|
1068
|
+
id: plugin.id,
|
|
1069
|
+
name: plugin.name,
|
|
1070
|
+
slug: plugin.slug,
|
|
1071
|
+
path: plugin.path,
|
|
1072
|
+
exists: plugin.exists,
|
|
1073
|
+
error: plugin.error,
|
|
1074
|
+
statuses: ["unknown_svn_state"],
|
|
1075
|
+
releaseBlocked: false,
|
|
1076
|
+
messages: plugin.error ? [plugin.error] : []
|
|
1077
|
+
};
|
|
1078
|
+
}
|
|
1079
|
+
try {
|
|
1080
|
+
const versionState = await getVersionState(plugin.path);
|
|
1081
|
+
return {
|
|
1082
|
+
id: plugin.id,
|
|
1083
|
+
name: plugin.name,
|
|
1084
|
+
slug: plugin.slug,
|
|
1085
|
+
path: plugin.path,
|
|
1086
|
+
exists: plugin.exists,
|
|
1087
|
+
localVersion: versionState.localVersion,
|
|
1088
|
+
readmeStableTag: versionState.readmeStableTag,
|
|
1089
|
+
remoteVersion: versionState.remoteVersion,
|
|
1090
|
+
latestSvnTag: versionState.latestSvnTag,
|
|
1091
|
+
statuses: versionState.statuses,
|
|
1092
|
+
releaseBlocked: versionState.releaseBlocked,
|
|
1093
|
+
messages: versionState.messages
|
|
1094
|
+
};
|
|
1095
|
+
}
|
|
1096
|
+
catch (error) {
|
|
1097
|
+
return {
|
|
1098
|
+
id: plugin.id,
|
|
1099
|
+
name: plugin.name,
|
|
1100
|
+
slug: plugin.slug,
|
|
1101
|
+
path: plugin.path,
|
|
1102
|
+
exists: plugin.exists,
|
|
1103
|
+
error: error instanceof Error ? error.message : String(error),
|
|
1104
|
+
statuses: ["unknown_svn_state"],
|
|
1105
|
+
releaseBlocked: false,
|
|
1106
|
+
messages: [error instanceof Error ? error.message : String(error)]
|
|
1107
|
+
};
|
|
1108
|
+
}
|
|
1109
|
+
}));
|
|
1110
|
+
return { plugins: entries };
|
|
1111
|
+
}
|
|
1112
|
+
function sendReleaseError(response, error) {
|
|
1113
|
+
if (error instanceof ReleaseError) {
|
|
1114
|
+
sendJson(response, error.status, { error: { message: error.message, code: error.code } });
|
|
1115
|
+
return;
|
|
1116
|
+
}
|
|
1117
|
+
sendJson(response, 500, {
|
|
1118
|
+
error: { message: error instanceof Error ? error.message : String(error) }
|
|
1119
|
+
});
|
|
1120
|
+
}
|
|
1121
|
+
async function detectPublishRoute(inputDir, rootDir, slug, name, requestedAction) {
|
|
1122
|
+
const { resolvePublishRoute } = await import("../wordpress-org/publish.js");
|
|
1123
|
+
if (requestedAction !== "auto") {
|
|
1124
|
+
return resolvePublishRoute({
|
|
1125
|
+
forceSubmit: requestedAction === "submit",
|
|
1126
|
+
forceRelease: requestedAction === "release"
|
|
1127
|
+
});
|
|
1128
|
+
}
|
|
1129
|
+
const source = resolvePluginProjectPath(inputDir);
|
|
1130
|
+
const [pending, svnExists] = await Promise.all([
|
|
1131
|
+
hasSavedSession()
|
|
1132
|
+
.then((hasSession) => hasSession
|
|
1133
|
+
? fetchPluginStates()
|
|
1134
|
+
.then((states) => states.some((state) => matchesPluginState(state, slug) || matchesPluginState(state, name)))
|
|
1135
|
+
.catch(() => undefined)
|
|
1136
|
+
: undefined),
|
|
1137
|
+
svnRepositoryExists(slug).catch(() => undefined)
|
|
1138
|
+
]);
|
|
1139
|
+
const route = resolvePublishRoute({
|
|
1140
|
+
hasPendingSubmission: pending,
|
|
1141
|
+
svnRepositoryExists: svnExists,
|
|
1142
|
+
isLocalSvnWorkingCopy: Boolean(source.svnRootDir || resolvePluginProjectPath(rootDir).svnRootDir),
|
|
1143
|
+
canPrompt: false
|
|
1144
|
+
});
|
|
1145
|
+
if (route.action === "prompt") {
|
|
1146
|
+
throw new Error("Could not determine whether to submit or release. Choose Submit or Release explicitly.");
|
|
1147
|
+
}
|
|
1148
|
+
return route;
|
|
1149
|
+
}
|
|
1150
|
+
async function requireLocalPlugin(id) {
|
|
1151
|
+
const plugin = await getLocalPlugin(id);
|
|
1152
|
+
if (!plugin) {
|
|
1153
|
+
throw new Error("Local plugin was not found.");
|
|
1154
|
+
}
|
|
1155
|
+
if (plugin.error) {
|
|
1156
|
+
throw new Error(plugin.error);
|
|
1157
|
+
}
|
|
1158
|
+
return plugin;
|
|
1159
|
+
}
|
|
1160
|
+
async function assertWebReleaseCredentials(slug) {
|
|
1161
|
+
const username = await inferWordPressOrgUsername();
|
|
1162
|
+
if (!username) {
|
|
1163
|
+
throw new Error("Could not infer a WordPress.org username. Log in with `pressship login` before releasing from Pressship Studio.");
|
|
1164
|
+
}
|
|
1165
|
+
if (!(await getSavedSvnPassword(username))) {
|
|
1166
|
+
throw new Error(`No saved WordPress.org SVN password found for ${username}. Generate one at ${getSvnPasswordUrl(username)}, then run a CLI release once or save credentials before releasing ${slug} from Pressship Studio.`);
|
|
1167
|
+
}
|
|
1168
|
+
}
|
|
1169
|
+
async function inferWordPressOrgUsername() {
|
|
1170
|
+
return (await hasSavedSession()) ? (await getWordPressOrgAccount().then((account) => account.username).catch(() => undefined)) : undefined;
|
|
1171
|
+
}
|
|
1172
|
+
function createApproval(approvals, approval) {
|
|
1173
|
+
const id = createHash("sha256")
|
|
1174
|
+
.update(`${approval.localId}:${approval.action}:${approval.version}:${Date.now()}:${Math.random()}`)
|
|
1175
|
+
.digest("hex")
|
|
1176
|
+
.slice(0, 24);
|
|
1177
|
+
const value = { ...approval, id, createdAt: Date.now() };
|
|
1178
|
+
approvals.set(id, value);
|
|
1179
|
+
return value;
|
|
1180
|
+
}
|
|
1181
|
+
async function fetchHostedReadme(slug) {
|
|
1182
|
+
const response = await fetch(`https://plugins.svn.wordpress.org/${encodeURIComponent(slug)}/trunk/readme.txt`);
|
|
1183
|
+
if (!response.ok) {
|
|
1184
|
+
throw new Error(`Could not fetch readme.txt for ${slug}.`);
|
|
1185
|
+
}
|
|
1186
|
+
return response.text();
|
|
1187
|
+
}
|
|
1188
|
+
async function readTextFile(filePath) {
|
|
1189
|
+
return (await import("node:fs/promises")).readFile(filePath, "utf8");
|
|
1190
|
+
}
|
|
1191
|
+
class StudioAiChangeConflictError extends Error {
|
|
1192
|
+
}
|
|
1193
|
+
async function waitForChildProcess(child, signal) {
|
|
1194
|
+
return new Promise((resolve, reject) => {
|
|
1195
|
+
const onAbort = () => {
|
|
1196
|
+
child.kill("SIGTERM");
|
|
1197
|
+
reject(new Error("AI assistance was cancelled."));
|
|
1198
|
+
};
|
|
1199
|
+
const cleanup = () => {
|
|
1200
|
+
signal.removeEventListener("abort", onAbort);
|
|
1201
|
+
child.off("error", onError);
|
|
1202
|
+
child.off("exit", onExit);
|
|
1203
|
+
};
|
|
1204
|
+
const onError = (error) => {
|
|
1205
|
+
cleanup();
|
|
1206
|
+
reject(error);
|
|
1207
|
+
};
|
|
1208
|
+
const onExit = (code, exitSignal) => {
|
|
1209
|
+
cleanup();
|
|
1210
|
+
resolve({ code, signal: exitSignal });
|
|
1211
|
+
};
|
|
1212
|
+
if (signal.aborted) {
|
|
1213
|
+
onAbort();
|
|
1214
|
+
return;
|
|
1215
|
+
}
|
|
1216
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
1217
|
+
child.once("error", onError);
|
|
1218
|
+
child.once("exit", onExit);
|
|
1219
|
+
});
|
|
1220
|
+
}
|
|
1221
|
+
async function createStudioAiPreviewWorkspace(pluginPath) {
|
|
1222
|
+
const sourceRoot = path.resolve(pluginPath);
|
|
1223
|
+
const workspaceRoot = await mkdtemp(path.join(tmpdir(), "pressship-studio-ai-"));
|
|
1224
|
+
const workspacePath = path.join(workspaceRoot, path.basename(sourceRoot) || "plugin");
|
|
1225
|
+
const ignoredDirectories = new Set([
|
|
1226
|
+
".git",
|
|
1227
|
+
".svn",
|
|
1228
|
+
"node_modules",
|
|
1229
|
+
"vendor",
|
|
1230
|
+
"build",
|
|
1231
|
+
"dist",
|
|
1232
|
+
"playground",
|
|
1233
|
+
".wordpress-playground",
|
|
1234
|
+
".pressship-svn"
|
|
1235
|
+
]);
|
|
1236
|
+
await cp(sourceRoot, workspacePath, {
|
|
1237
|
+
recursive: true,
|
|
1238
|
+
filter(sourcePath) {
|
|
1239
|
+
const relativePath = path.relative(sourceRoot, sourcePath);
|
|
1240
|
+
if (!relativePath) {
|
|
1241
|
+
return true;
|
|
1242
|
+
}
|
|
1243
|
+
return !relativePath.split(path.sep).some((part) => ignoredDirectories.has(part));
|
|
1244
|
+
}
|
|
1245
|
+
});
|
|
1246
|
+
return { root: workspaceRoot, path: workspacePath };
|
|
1247
|
+
}
|
|
1248
|
+
async function snapshotStudioFileContents(pluginPath) {
|
|
1249
|
+
const root = path.resolve(pluginPath);
|
|
1250
|
+
const { files } = await listStudioFiles(root);
|
|
1251
|
+
const entries = await Promise.all(files.map(async (file) => {
|
|
1252
|
+
const filePath = path.join(root, file.path);
|
|
1253
|
+
const [fileStats, content] = await Promise.all([stat(filePath), readFile(filePath, "utf8")]);
|
|
1254
|
+
return [
|
|
1255
|
+
file.path,
|
|
1256
|
+
{
|
|
1257
|
+
size: fileStats.size,
|
|
1258
|
+
mtimeMs: fileStats.mtimeMs,
|
|
1259
|
+
content
|
|
1260
|
+
}
|
|
1261
|
+
];
|
|
1262
|
+
}));
|
|
1263
|
+
return new Map(entries);
|
|
1264
|
+
}
|
|
1265
|
+
function diffStudioFileContents(before, after) {
|
|
1266
|
+
const changes = [];
|
|
1267
|
+
for (const [filePath, afterValue] of after.entries()) {
|
|
1268
|
+
const beforeValue = before.get(filePath);
|
|
1269
|
+
if (!beforeValue) {
|
|
1270
|
+
changes.push(createStudioFileChange(filePath, "created", undefined, afterValue.content));
|
|
1271
|
+
}
|
|
1272
|
+
else if (beforeValue.content !== afterValue.content) {
|
|
1273
|
+
changes.push(createStudioFileChange(filePath, "modified", beforeValue.content, afterValue.content));
|
|
1274
|
+
}
|
|
1275
|
+
}
|
|
1276
|
+
for (const [filePath, beforeValue] of before.entries()) {
|
|
1277
|
+
if (!after.has(filePath)) {
|
|
1278
|
+
changes.push(createStudioFileChange(filePath, "deleted", beforeValue.content, undefined));
|
|
1279
|
+
}
|
|
1280
|
+
}
|
|
1281
|
+
return changes.sort((a, b) => a.path.localeCompare(b.path));
|
|
1282
|
+
}
|
|
1283
|
+
function createStudioFileChange(filePath, status, beforeContent, afterContent) {
|
|
1284
|
+
const hunks = createStudioFileDiffHunks(beforeContent ?? "", afterContent ?? "");
|
|
1285
|
+
return {
|
|
1286
|
+
path: filePath,
|
|
1287
|
+
status,
|
|
1288
|
+
beforeContent,
|
|
1289
|
+
afterContent,
|
|
1290
|
+
additions: countStudioFileDiffLines(hunks, "add"),
|
|
1291
|
+
deletions: countStudioFileDiffLines(hunks, "delete"),
|
|
1292
|
+
hunks
|
|
1293
|
+
};
|
|
1294
|
+
}
|
|
1295
|
+
function countStudioFileDiffLines(hunks, type) {
|
|
1296
|
+
return hunks.reduce((count, hunk) => count + hunk.lines.filter((line) => line.type === type).length, 0);
|
|
1297
|
+
}
|
|
1298
|
+
function createStudioFileDiffHunks(beforeContent, afterContent) {
|
|
1299
|
+
if (beforeContent === afterContent) {
|
|
1300
|
+
return [];
|
|
1301
|
+
}
|
|
1302
|
+
const beforeLines = splitStudioFileContentLines(beforeContent);
|
|
1303
|
+
const afterLines = splitStudioFileContentLines(afterContent);
|
|
1304
|
+
let prefix = 0;
|
|
1305
|
+
while (prefix < beforeLines.length &&
|
|
1306
|
+
prefix < afterLines.length &&
|
|
1307
|
+
beforeLines[prefix] === afterLines[prefix]) {
|
|
1308
|
+
prefix += 1;
|
|
1309
|
+
}
|
|
1310
|
+
let beforeEnd = beforeLines.length - 1;
|
|
1311
|
+
let afterEnd = afterLines.length - 1;
|
|
1312
|
+
while (beforeEnd >= prefix && afterEnd >= prefix && beforeLines[beforeEnd] === afterLines[afterEnd]) {
|
|
1313
|
+
beforeEnd -= 1;
|
|
1314
|
+
afterEnd -= 1;
|
|
1315
|
+
}
|
|
1316
|
+
const contextBeforeStart = Math.max(0, prefix - 3);
|
|
1317
|
+
const contextAfterBeforeEnd = Math.min(beforeLines.length - 1, beforeEnd + 3);
|
|
1318
|
+
const contextAfterAfterEnd = Math.min(afterLines.length - 1, afterEnd + 3);
|
|
1319
|
+
const leadingContext = beforeLines.slice(contextBeforeStart, prefix);
|
|
1320
|
+
const removed = beforeLines.slice(prefix, beforeEnd + 1);
|
|
1321
|
+
const added = afterLines.slice(prefix, afterEnd + 1);
|
|
1322
|
+
const trailingBefore = beforeLines.slice(beforeEnd + 1, contextAfterBeforeEnd + 1);
|
|
1323
|
+
const trailingAfter = afterLines.slice(afterEnd + 1, contextAfterAfterEnd + 1);
|
|
1324
|
+
const trailingContext = trailingBefore.length === trailingAfter.length ? trailingBefore : [];
|
|
1325
|
+
const lines = [
|
|
1326
|
+
...leadingContext.map((content) => ({ type: "context", content })),
|
|
1327
|
+
...removed.map((content) => ({ type: "delete", content })),
|
|
1328
|
+
...added.map((content) => ({ type: "add", content })),
|
|
1329
|
+
...trailingContext.map((content) => ({ type: "context", content }))
|
|
1330
|
+
];
|
|
1331
|
+
return [
|
|
1332
|
+
{
|
|
1333
|
+
oldStart: contextBeforeStart + 1,
|
|
1334
|
+
oldLines: leadingContext.length + removed.length + trailingContext.length,
|
|
1335
|
+
newStart: contextBeforeStart + 1,
|
|
1336
|
+
newLines: leadingContext.length + added.length + trailingContext.length,
|
|
1337
|
+
lines
|
|
1338
|
+
}
|
|
1339
|
+
];
|
|
1340
|
+
}
|
|
1341
|
+
function splitStudioFileContentLines(content) {
|
|
1342
|
+
if (!content) {
|
|
1343
|
+
return [];
|
|
1344
|
+
}
|
|
1345
|
+
return content.replace(/\r\n/g, "\n").split("\n");
|
|
1346
|
+
}
|
|
1347
|
+
function streamJobEvents(response, jobs, id) {
|
|
1348
|
+
response.writeHead(200, {
|
|
1349
|
+
"Content-Type": "text/event-stream",
|
|
1350
|
+
"Cache-Control": "no-cache, no-transform",
|
|
1351
|
+
Connection: "keep-alive"
|
|
1352
|
+
});
|
|
1353
|
+
const unsubscribe = jobs.subscribe(id, (event) => {
|
|
1354
|
+
response.write(`id: ${event.id}\n`);
|
|
1355
|
+
response.write(`event: ${event.type === "error" ? "job-error" : event.type}\n`);
|
|
1356
|
+
response.write(`data: ${JSON.stringify(event)}\n\n`);
|
|
1357
|
+
}, () => response.end());
|
|
1358
|
+
response.on("close", unsubscribe);
|
|
1359
|
+
}
|
|
1360
|
+
async function serveStatic(response, staticDir, requestPath, token) {
|
|
1361
|
+
const filePath = path.join(staticDir, requestPath === "/" ? "index.html" : requestPath);
|
|
1362
|
+
if (!filePath.startsWith(staticDir)) {
|
|
1363
|
+
sendJson(response, 403, { error: { message: "Forbidden." } });
|
|
1364
|
+
return;
|
|
1365
|
+
}
|
|
1366
|
+
if (path.basename(filePath) === "index.html") {
|
|
1367
|
+
const html = (await import("node:fs/promises")).readFile(filePath, "utf8");
|
|
1368
|
+
response.writeHead(200, {
|
|
1369
|
+
"Cache-Control": "no-store",
|
|
1370
|
+
"Content-Type": "text/html; charset=utf-8"
|
|
1371
|
+
});
|
|
1372
|
+
response.end((await html).replace("__PRESSSHIP_TOKEN__", token));
|
|
1373
|
+
return;
|
|
1374
|
+
}
|
|
1375
|
+
const type = contentType(filePath);
|
|
1376
|
+
response.writeHead(200, {
|
|
1377
|
+
"Cache-Control": "no-store",
|
|
1378
|
+
"Content-Type": type
|
|
1379
|
+
});
|
|
1380
|
+
createReadStream(filePath)
|
|
1381
|
+
.on("error", () => sendJson(response, 404, { error: { message: "Not found." } }))
|
|
1382
|
+
.pipe(response);
|
|
1383
|
+
}
|
|
1384
|
+
async function readJson(request) {
|
|
1385
|
+
const chunks = [];
|
|
1386
|
+
for await (const chunk of request) {
|
|
1387
|
+
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
|
|
1388
|
+
}
|
|
1389
|
+
const raw = Buffer.concat(chunks).toString("utf8");
|
|
1390
|
+
return raw ? JSON.parse(raw) : {};
|
|
1391
|
+
}
|
|
1392
|
+
function sendJson(response, statusCode, body) {
|
|
1393
|
+
if (response.headersSent) {
|
|
1394
|
+
response.end();
|
|
1395
|
+
return;
|
|
1396
|
+
}
|
|
1397
|
+
response.writeHead(statusCode, { "Content-Type": "application/json; charset=utf-8" });
|
|
1398
|
+
response.end(JSON.stringify(body, null, 2));
|
|
1399
|
+
}
|
|
1400
|
+
function resolveStaticDir() {
|
|
1401
|
+
const currentDir = path.dirname(fileURLToPath(import.meta.url));
|
|
1402
|
+
return path.resolve(currentDir, "../../assets/web");
|
|
1403
|
+
}
|
|
1404
|
+
function contentType(filePath) {
|
|
1405
|
+
if (filePath.endsWith(".css")) {
|
|
1406
|
+
return "text/css; charset=utf-8";
|
|
1407
|
+
}
|
|
1408
|
+
if (filePath.endsWith(".js")) {
|
|
1409
|
+
return "text/javascript; charset=utf-8";
|
|
1410
|
+
}
|
|
1411
|
+
if (filePath.endsWith(".svg")) {
|
|
1412
|
+
return "image/svg+xml";
|
|
1413
|
+
}
|
|
1414
|
+
if (filePath.endsWith(".png")) {
|
|
1415
|
+
return "image/png";
|
|
1416
|
+
}
|
|
1417
|
+
return "application/octet-stream";
|
|
1418
|
+
}
|
|
1419
|
+
//# sourceMappingURL=server.js.map
|