pressship 0.1.13 → 0.1.15

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.
@@ -1,6 +1,6 @@
1
1
  import { createHash, randomBytes } from "node:crypto";
2
2
  import { createReadStream } from "node:fs";
3
- import { cp, mkdir, mkdtemp, readFile, rm, stat, unlink, writeFile } from "node:fs/promises";
3
+ import { cp, mkdir, mkdtemp, readdir, readFile, rm, stat, unlink, writeFile } from "node:fs/promises";
4
4
  import { createServer } from "node:http";
5
5
  import { createRequire } from "node:module";
6
6
  import { tmpdir } from "node:os";
@@ -12,6 +12,8 @@ import fg from "fast-glob";
12
12
  import { z } from "zod";
13
13
  import { hasSavedSession } from "../auth/session.js";
14
14
  import { getWordPressOrgAccount } from "../auth/whoami.js";
15
+ import { analyzePluginPackage, stagePluginDirectory } from "../package/archive.js";
16
+ import { addPressshipIgnorePattern, createPressshipIgnoreMatcher, hardIgnoreDirectories, isHardIgnoredPath, pressshipIgnoreFile, readPressshipIgnorePatterns, removePressshipIgnorePattern } from "../package/ignore.js";
15
17
  import { createPluginPack, summarizePackResult, validatePluginPack } from "../package/pack.js";
16
18
  import { discoverPluginProject, resolvePluginProjectPath } from "../plugin/discover.js";
17
19
  import { assertDemoLaunchPlanSupported, createDemoLaunchPlan, prepareDemoRuntime, publicDemoLaunchPlan, resetPlaygroundSite } from "../plugin/demo.js";
@@ -25,7 +27,7 @@ import { publish } from "../wordpress-org/publish.js";
25
27
  import { fetchPluginStates, matchesPluginState } from "../wordpress-org/state.js";
26
28
  import { runPluginCheck } from "../checks/plugin-check.js";
27
29
  import { hasBlockingFindings } from "../checks/summary.js";
28
- import { ensureCacheDir, getConfigDir } from "../utils/paths.js";
30
+ import { ensureCacheDir, getConfigDir, pathExists } from "../utils/paths.js";
29
31
  import { addLocalPluginPath, getLocalPlugin, listLocalPlugins, removeLocalPlugin } from "./registry.js";
30
32
  import { WebJobManager } from "./jobs.js";
31
33
  import { getVersionState } from "./version-state.js";
@@ -37,11 +39,13 @@ import { addStudioPluginCheckLineHints, normalizeStudioPluginCheckFindings, summ
37
39
  import { readStudioPluginCheckState, removeStudioPluginCheckFindingsForFiles, removeStudioPluginCheckState, writeStudioPluginCheckState } from "./plugin-check-state.js";
38
40
  const nodeRequire = createRequire(import.meta.url);
39
41
  const mutationMethods = new Set(["POST", "PUT", "PATCH", "DELETE"]);
42
+ const spaRoutePrefixes = new Set(["dashboard", "studio", "wordpress.org", "remote", "local", "release", "settings"]);
40
43
  const addLocalPluginSchema = z.object({ path: z.string().min(1) });
41
44
  const bumpVersionSchema = z.object({ bump: z.enum(["patch", "minor", "major"]) });
42
45
  const setVersionSchema = z.object({ version: z.string().min(1) });
43
46
  const createSvnTagSchema = z.object({ name: z.string().min(1) });
44
47
  const switchSvnTagSchema = z.object({ conflictResolution: z.enum(["override", "revert"]).optional() });
48
+ const ignoreRuleSchema = z.object({ pattern: z.string().min(1) });
45
49
  const writeStudioFileSchema = z.object({
46
50
  path: z.string().min(1),
47
51
  content: z.string()
@@ -102,7 +106,9 @@ export async function startWebServer(options = {}) {
102
106
  const approvals = new Map();
103
107
  const playgrounds = new Map();
104
108
  const playgroundPortReservations = new Set();
109
+ const packageSizeCache = new Map();
105
110
  const staticDir = resolveStaticDir();
111
+ const dependencies = options.dependencies ?? {};
106
112
  const server = createServer((request, response) => {
107
113
  void handleRequest(request, response, {
108
114
  token,
@@ -110,7 +116,9 @@ export async function startWebServer(options = {}) {
110
116
  approvals,
111
117
  playgrounds,
112
118
  playgroundPortReservations,
113
- staticDir
119
+ packageSizeCache,
120
+ staticDir,
121
+ dependencies
114
122
  });
115
123
  });
116
124
  await new Promise((resolve, reject) => {
@@ -271,6 +279,62 @@ async function handleApi(request, response, url, context) {
271
279
  sendJson(response, 200, await listStudioFiles(plugin.path));
272
280
  return;
273
281
  }
282
+ const studioFilesDirectoryMatch = url.pathname.match(/^\/api\/plugins\/local\/([^/]+)\/files\/directory$/);
283
+ if (method === "GET" && studioFilesDirectoryMatch) {
284
+ const plugin = await requireLocalPlugin(decodeURIComponent(studioFilesDirectoryMatch[1]));
285
+ const relativePath = url.searchParams.get("path") ?? "";
286
+ sendJson(response, 200, await listStudioDirectory(plugin.path, relativePath));
287
+ return;
288
+ }
289
+ const studioIgnoreStateMatch = url.pathname.match(/^\/api\/plugins\/local\/([^/]+)\/ignore-state$/);
290
+ if (method === "GET" && studioIgnoreStateMatch) {
291
+ const plugin = await requireLocalPlugin(decodeURIComponent(studioIgnoreStateMatch[1]));
292
+ sendJson(response, 200, await readStudioIgnoreState(plugin.path));
293
+ return;
294
+ }
295
+ const studioPackageSizeMatch = url.pathname.match(/^\/api\/plugins\/local\/([^/]+)\/package-size$/);
296
+ if (method === "GET" && studioPackageSizeMatch) {
297
+ const plugin = await requireLocalPlugin(decodeURIComponent(studioPackageSizeMatch[1]));
298
+ sendJson(response, 200, readStudioPackageSize(plugin.path, context.packageSizeCache));
299
+ return;
300
+ }
301
+ const studioIgnoreRulesMatch = url.pathname.match(/^\/api\/plugins\/local\/([^/]+)\/ignore-rules$/);
302
+ if (method === "POST" && studioIgnoreRulesMatch) {
303
+ const plugin = await requireLocalPlugin(decodeURIComponent(studioIgnoreRulesMatch[1]));
304
+ const body = ignoreRuleSchema.parse(await readJson(request));
305
+ try {
306
+ await addPressshipIgnorePattern(path.resolve(plugin.path), body.pattern);
307
+ invalidateStudioPackageSize(plugin.path, context.packageSizeCache);
308
+ sendJson(response, 200, await readStudioIgnoreState(plugin.path));
309
+ }
310
+ catch (error) {
311
+ sendJson(response, 400, {
312
+ error: {
313
+ message: error instanceof Error ? error.message : String(error),
314
+ code: "invalid_ignore_pattern"
315
+ }
316
+ });
317
+ }
318
+ return;
319
+ }
320
+ if (method === "DELETE" && studioIgnoreRulesMatch) {
321
+ const plugin = await requireLocalPlugin(decodeURIComponent(studioIgnoreRulesMatch[1]));
322
+ const body = ignoreRuleSchema.parse(await readJson(request));
323
+ try {
324
+ await removePressshipIgnorePattern(path.resolve(plugin.path), body.pattern);
325
+ invalidateStudioPackageSize(plugin.path, context.packageSizeCache);
326
+ sendJson(response, 200, await readStudioIgnoreState(plugin.path));
327
+ }
328
+ catch (error) {
329
+ sendJson(response, 400, {
330
+ error: {
331
+ message: error instanceof Error ? error.message : String(error),
332
+ code: "invalid_ignore_pattern"
333
+ }
334
+ });
335
+ }
336
+ return;
337
+ }
274
338
  const studioCheckStateMatch = url.pathname.match(/^\/api\/plugins\/local\/([^/]+)\/check-state$/);
275
339
  if (method === "GET" && studioCheckStateMatch) {
276
340
  const localId = decodeURIComponent(studioCheckStateMatch[1]);
@@ -290,6 +354,7 @@ async function handleApi(request, response, url, context) {
290
354
  const plugin = await requireLocalPlugin(localId);
291
355
  const body = writeStudioFileSchema.parse(await readJson(request));
292
356
  const saved = await writeStudioFile(plugin.path, body.path, body.content);
357
+ invalidateStudioPackageSize(plugin.path, context.packageSizeCache);
293
358
  sendJson(response, 200, {
294
359
  ...saved,
295
360
  checkState: await removeStudioPluginCheckFindingsForFiles(localId, [saved.path])
@@ -303,6 +368,7 @@ async function handleApi(request, response, url, context) {
303
368
  const body = studioAiChangeSchema.parse(await readJson(request));
304
369
  try {
305
370
  const applied = await applyStudioAiChange(plugin.path, body);
371
+ invalidateStudioPackageSize(plugin.path, context.packageSizeCache);
306
372
  const checkState = await removeStudioPluginCheckFindingsForFiles(localId, [applied.path]);
307
373
  sendJson(response, 200, {
308
374
  ...applied,
@@ -325,6 +391,7 @@ async function handleApi(request, response, url, context) {
325
391
  const localId = decodeURIComponent(bumpMatch[1]);
326
392
  const plugin = await requireLocalPlugin(localId);
327
393
  await bumpLocalPluginVersion(plugin.path, body.bump);
394
+ invalidateStudioPackageSize(plugin.path, context.packageSizeCache);
328
395
  await addLocalPluginPath(plugin.path, plugin.source);
329
396
  await removeStudioPluginCheckState(localId);
330
397
  sendJson(response, 200, { ...(await getVersionState(plugin.path)), checkState: null });
@@ -347,6 +414,7 @@ async function handleApi(request, response, url, context) {
347
414
  }
348
415
  try {
349
416
  await setLocalPluginVersion(plugin.path, trimmed);
417
+ invalidateStudioPackageSize(plugin.path, context.packageSizeCache);
350
418
  await addLocalPluginPath(plugin.path, plugin.source);
351
419
  await removeStudioPluginCheckState(localId);
352
420
  sendJson(response, 200, { ...(await getVersionState(plugin.path)), checkState: null });
@@ -410,7 +478,7 @@ async function handleApi(request, response, url, context) {
410
478
  }
411
479
  if (method === "POST" && url.pathname === "/api/jobs") {
412
480
  const body = jobSchema.parse(await readJson(request));
413
- const job = createWebJob(body, context.jobs, context.approvals, context.playgrounds, context.playgroundPortReservations);
481
+ const job = createWebJob(body, context.jobs, context.approvals, context.playgrounds, context.playgroundPortReservations, context.dependencies);
414
482
  sendJson(response, 202, job);
415
483
  return;
416
484
  }
@@ -437,7 +505,7 @@ async function handleApi(request, response, url, context) {
437
505
  }
438
506
  sendJson(response, 404, { error: { message: "Not found.", code: "not_found" } });
439
507
  }
440
- function createWebJob(input, jobs, approvals, playgrounds, playgroundPortReservations) {
508
+ function createWebJob(input, jobs, approvals, playgrounds, playgroundPortReservations, dependencies = {}) {
441
509
  if (input.type === "clone") {
442
510
  return jobs.create("clone", `Clone/update ${input.slug}`, (context) => clonePluginJob(input, context));
443
511
  }
@@ -445,7 +513,7 @@ function createWebJob(input, jobs, approvals, playgrounds, playgroundPortReserva
445
513
  return jobs.create("play", `Start Playground for ${input.id}`, (context) => playPluginJob(input, playgrounds, playgroundPortReservations, context));
446
514
  }
447
515
  if (input.type === "check") {
448
- return jobs.create("check", "Plugin Check", (context) => pluginCheckJob(input.localId, context));
516
+ return jobs.create("check", "Plugin Check", (context) => pluginCheckJob(input.localId, context, dependencies));
449
517
  }
450
518
  if (input.type === "ai-chat") {
451
519
  return jobs.create("ai-chat", "AI Assistance", (context) => aiChatJob(input, context));
@@ -569,41 +637,50 @@ async function playPluginJob(input, playgrounds, playgroundPortReservations, con
569
637
  throw error;
570
638
  }
571
639
  }
572
- async function pluginCheckJob(localId, context) {
640
+ async function pluginCheckJob(localId, context, dependencies = {}) {
573
641
  const local = await requireLocalPlugin(localId);
574
642
  const source = resolvePluginProjectPath(local.path);
575
643
  const project = await discoverPluginProject(source.rootDir);
644
+ const stagePlugin = dependencies.stagePluginDirectory ?? stagePluginDirectory;
645
+ const pluginChecker = dependencies.runPluginCheck ?? runPluginCheck;
646
+ const stageRoot = await mkdtemp(path.join(tmpdir(), "pressship-studio-check-"));
576
647
  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,
648
+ try {
649
+ const checkTarget = await stagePlugin(project, { outputDir: stageRoot });
650
+ const result = await pluginChecker(checkTarget.path, { mode: "new" });
651
+ const findings = await addStudioPluginCheckLineHints(normalizeStudioPluginCheckFindings(result.findings, checkTarget.path, project.slug), source.rootDir);
652
+ const summary = summarizeStudioPluginCheckFindings(findings);
653
+ const checkedAt = new Date().toISOString();
654
+ const persisted = await writeStudioPluginCheckState({
655
+ pluginId: local.id,
656
+ pluginPath: local.path,
597
657
  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
- };
658
+ name: local.name,
659
+ skipped: result.skipped,
660
+ available: result.available,
661
+ findings,
662
+ summary,
663
+ checkedAt
664
+ });
665
+ context.log(`Plugin Check finished: ${summary.error} errors, ${summary.warning} warnings, ${summary.info} info.`);
666
+ return {
667
+ plugin: {
668
+ id: local.id,
669
+ name: local.name,
670
+ slug: local.slug,
671
+ path: local.path
672
+ },
673
+ skipped: result.skipped,
674
+ available: result.available,
675
+ findings,
676
+ summary,
677
+ checkedAt: persisted.checkedAt,
678
+ rawOutput: result.rawOutput
679
+ };
680
+ }
681
+ finally {
682
+ await rm(stageRoot, { recursive: true, force: true });
683
+ }
607
684
  }
608
685
  async function aiChatJob(input, context) {
609
686
  const local = await requireLocalPlugin(input.localId);
@@ -680,12 +757,13 @@ async function dryRunPublishJob(localId, requestedAction, approvals, context) {
680
757
  if (route.action === "release" && versionState.releaseBlocked) {
681
758
  context.log("Release is blocked by version state.", versionState);
682
759
  }
760
+ const ignorePatterns = await readPressshipIgnorePatterns(source.rootDir);
683
761
  context.status("Validating package.");
684
- const validation = await validatePluginPack(project, {});
762
+ const validation = await validatePluginPack(project, { ignore: ignorePatterns });
685
763
  const validationBlocked = hasBlockingFindings(validation.readmeFindings) || hasBlockingFindings(validation.pluginCheckFindings);
686
764
  const cacheDir = path.join(await ensureCacheDir(), "studio-packages");
687
765
  await mkdir(cacheDir, { recursive: true, mode: 0o700 });
688
- const pack = summarizePackResult(await createPluginPack(source.rootDir, { outputDir: cacheDir }), validation);
766
+ const pack = summarizePackResult(await createPluginPack(source.rootDir, { outputDir: cacheDir, ignore: ignorePatterns }), validation);
689
767
  const releasePlan = route.action === "release"
690
768
  ? 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
769
  : undefined;
@@ -695,7 +773,8 @@ async function dryRunPublishJob(localId, requestedAction, approvals, context) {
695
773
  localId,
696
774
  pluginPath: source.inputDir,
697
775
  action: route.action,
698
- version: project.version
776
+ version: project.version,
777
+ ignore: ignorePatterns
699
778
  })
700
779
  : undefined;
701
780
  return {
@@ -728,6 +807,7 @@ async function confirmPublishJob(approvalId, overview, approvals, context) {
728
807
  yes: true,
729
808
  submit: approval.action === "submit",
730
809
  release: approval.action === "release",
810
+ ignore: approval.ignore ?? [],
731
811
  overview: overview ?? project.headers.description ?? ""
732
812
  });
733
813
  approvals.delete(approvalId);
@@ -749,39 +829,366 @@ async function readPluginDetail(scope, id) {
749
829
  readme: await fetchHostedReadme(id).catch(() => undefined)
750
830
  };
751
831
  }
832
+ const studioHiddenFilePatterns = [
833
+ "**/.git/**",
834
+ "**/.svn/**",
835
+ "**/node_modules/**",
836
+ "**/vendor/**",
837
+ "**/build/**",
838
+ "**/dist/**",
839
+ "**/playground/**",
840
+ "**/.wordpress-playground/**"
841
+ ];
842
+ const maxStudioEditableFileBytes = 1_000_000;
843
+ const studioAlwaysHiddenDirectoryNames = new Set([".git"]);
844
+ const studioDeferredDirectoryNames = new Set([
845
+ ...hardIgnoreDirectories,
846
+ "vendor",
847
+ ".wordpress-playground",
848
+ "playground"
849
+ ]);
850
+ const studioDeferredDirectoryPatterns = Array.from(studioDeferredDirectoryNames).flatMap((directory) => [
851
+ `${directory}/**`,
852
+ `**/${directory}/**`
853
+ ]);
854
+ const studioEditableExtensions = new Set([
855
+ ".php",
856
+ ".js",
857
+ ".jsx",
858
+ ".ts",
859
+ ".tsx",
860
+ ".css",
861
+ ".scss",
862
+ ".sass",
863
+ ".html",
864
+ ".htm",
865
+ ".json",
866
+ ".md",
867
+ ".txt",
868
+ ".xml",
869
+ ".yml",
870
+ ".yaml",
871
+ ".po",
872
+ ".pot",
873
+ ".ini",
874
+ ".sh",
875
+ ".svg"
876
+ ]);
877
+ const studioEditableFileNames = new Set([
878
+ "composer.json",
879
+ "package.json",
880
+ "readme.txt",
881
+ "license",
882
+ "license.txt",
883
+ pressshipIgnoreFile
884
+ ]);
752
885
  async function listStudioFiles(pluginPath) {
753
886
  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
- ], {
887
+ const patterns = await readPressshipIgnorePatterns(root);
888
+ const matcher = createPressshipIgnoreMatcher(patterns);
889
+ const [files, deferredDirectories, ignoredDirectories] = await Promise.all([
890
+ fg("**/*", {
891
+ cwd: root,
892
+ onlyFiles: true,
893
+ dot: true,
894
+ unique: true,
895
+ ignore: studioDeferredDirectoryPatterns
896
+ }),
897
+ listStudioDeferredDirectories(root, matcher),
898
+ listStudioIgnoredPatternDirectories(root, patterns, matcher)
899
+ ]);
900
+ const directories = mergeStudioDirectoryEntries([...deferredDirectories, ...ignoredDirectories]);
901
+ const entries = await Promise.all(files.sort((a, b) => a.localeCompare(b)).map(async (relativePath) => {
902
+ const fileStats = await stat(path.join(root, relativePath));
903
+ const ignoredBy = matcher.ignoredBy(relativePath);
904
+ return {
905
+ path: relativePath,
906
+ name: path.basename(relativePath),
907
+ directory: path.dirname(relativePath) === "." ? "" : path.dirname(relativePath),
908
+ size: fileStats.size,
909
+ hardIgnored: isHardIgnoredPath(relativePath),
910
+ ignored: Boolean(ignoredBy),
911
+ ignoredBy
912
+ };
913
+ }));
914
+ return { files: entries, directories };
915
+ }
916
+ async function listStudioDirectory(pluginPath, relativePath) {
917
+ const root = path.resolve(pluginPath);
918
+ const normalized = normalizeStudioRelativePath(relativePath);
919
+ if (!normalized) {
920
+ throw new Error("Choose a folder first.");
921
+ }
922
+ if (studioExplorerDirectoryIsAlwaysHidden(normalized)) {
923
+ return { files: [], directories: [] };
924
+ }
925
+ const directoryPath = path.resolve(root, normalized);
926
+ if (directoryPath !== root && !directoryPath.startsWith(`${root}${path.sep}`)) {
927
+ throw new Error("Folder is outside the plugin directory.");
928
+ }
929
+ const directoryStats = await stat(directoryPath);
930
+ if (!directoryStats.isDirectory()) {
931
+ throw new Error("Studio can only load folders.");
932
+ }
933
+ const patterns = await readPressshipIgnorePatterns(root);
934
+ const matcher = createPressshipIgnoreMatcher(patterns);
935
+ const ignoredBy = studioIgnoredBy(normalized, matcher);
936
+ const entries = await readdir(directoryPath, { withFileTypes: true });
937
+ const files = [];
938
+ const directories = [
939
+ {
940
+ path: normalized,
941
+ name: path.basename(normalized),
942
+ directory: path.dirname(normalized) === "." ? "" : path.dirname(normalized),
943
+ deferred: false,
944
+ ignored: Boolean(ignoredBy),
945
+ ignoredBy,
946
+ hardIgnored: isHardIgnoredPath(normalized)
947
+ }
948
+ ];
949
+ for (const entry of entries.sort((a, b) => a.name.localeCompare(b.name))) {
950
+ const childPath = `${normalized}/${entry.name}`;
951
+ if (studioExplorerDirectoryIsAlwaysHidden(childPath)) {
952
+ continue;
953
+ }
954
+ const childIgnoredBy = studioIgnoredBy(childPath, matcher);
955
+ if (entry.isDirectory()) {
956
+ directories.push({
957
+ path: childPath,
958
+ name: entry.name,
959
+ directory: normalized,
960
+ deferred: true,
961
+ ignored: Boolean(childIgnoredBy),
962
+ ignoredBy: childIgnoredBy,
963
+ hardIgnored: isHardIgnoredPath(childPath)
964
+ });
965
+ continue;
966
+ }
967
+ if (!entry.isFile()) {
968
+ continue;
969
+ }
970
+ const fileStats = await stat(path.join(directoryPath, entry.name));
971
+ files.push({
972
+ path: childPath,
973
+ name: entry.name,
974
+ directory: normalized,
975
+ size: fileStats.size,
976
+ hardIgnored: isHardIgnoredPath(childPath),
977
+ ignored: Boolean(childIgnoredBy),
978
+ ignoredBy: childIgnoredBy
979
+ });
980
+ }
981
+ return { files, directories };
982
+ }
983
+ async function listStudioDeferredDirectories(root, matcher) {
984
+ const entries = await readdir(root, { withFileTypes: true });
985
+ return entries
986
+ .filter((entry) => entry.isDirectory() &&
987
+ studioDeferredDirectoryNames.has(entry.name) &&
988
+ !studioExplorerDirectoryIsAlwaysHidden(entry.name))
989
+ .sort((a, b) => a.name.localeCompare(b.name))
990
+ .map((entry) => {
991
+ const ignoredBy = isHardIgnoredPath(entry.name)
992
+ ? "Pressship package rules"
993
+ : matcher.ignoredBy(entry.name) ?? matcher.ignoredBy(`${entry.name}/.pressship-directory`);
994
+ return {
995
+ path: entry.name,
996
+ name: entry.name,
997
+ directory: "",
998
+ deferred: true,
999
+ ignored: Boolean(ignoredBy),
1000
+ ignoredBy,
1001
+ hardIgnored: isHardIgnoredPath(entry.name)
1002
+ };
1003
+ });
1004
+ }
1005
+ async function listStudioIgnoredPatternDirectories(root, patterns, matcher) {
1006
+ const candidates = Array.from(new Set(patterns.flatMap(studioIgnoredDirectoryCandidates)));
1007
+ const entries = await Promise.all(candidates.map(async (relativePath) => {
1008
+ if (studioExplorerDirectoryIsAlwaysHidden(relativePath)) {
1009
+ return undefined;
1010
+ }
1011
+ const absolutePath = path.join(root, relativePath);
1012
+ if (!pathExists(absolutePath)) {
1013
+ return undefined;
1014
+ }
1015
+ const entryStats = await stat(absolutePath);
1016
+ if (!entryStats.isDirectory()) {
1017
+ return undefined;
1018
+ }
1019
+ const ignoredBy = matcher.ignoredBy(relativePath) ?? matcher.ignoredBy(`${relativePath}/.pressship-directory`);
1020
+ if (!ignoredBy) {
1021
+ return undefined;
1022
+ }
1023
+ return {
1024
+ path: relativePath,
1025
+ name: path.basename(relativePath),
1026
+ directory: path.dirname(relativePath) === "." ? "" : path.dirname(relativePath),
1027
+ deferred: false,
1028
+ ignored: true,
1029
+ ignoredBy,
1030
+ hardIgnored: isHardIgnoredPath(relativePath)
1031
+ };
1032
+ }));
1033
+ return entries
1034
+ .filter((entry) => Boolean(entry))
1035
+ .sort((a, b) => a.path.localeCompare(b.path));
1036
+ }
1037
+ function studioIgnoredDirectoryCandidates(pattern) {
1038
+ const trimmed = pattern.trim();
1039
+ if (!trimmed || trimmed.startsWith("!")) {
1040
+ return [];
1041
+ }
1042
+ let candidate = "";
1043
+ if (trimmed.endsWith("/**")) {
1044
+ candidate = trimmed.slice(0, -3);
1045
+ }
1046
+ else if (trimmed.endsWith("/")) {
1047
+ candidate = trimmed.slice(0, -1);
1048
+ }
1049
+ else if (!hasStudioGlobMagic(trimmed)) {
1050
+ candidate = trimmed;
1051
+ }
1052
+ candidate = candidate.replace(/^\.\/+/, "").replace(/\/+$/, "");
1053
+ if (!candidate || candidate === "." || hasStudioGlobMagic(candidate)) {
1054
+ return [];
1055
+ }
1056
+ return [candidate];
1057
+ }
1058
+ function hasStudioGlobMagic(value) {
1059
+ return /[*?[\]{}]/.test(value);
1060
+ }
1061
+ function studioIgnoredBy(relativePath, matcher) {
1062
+ return isHardIgnoredPath(relativePath)
1063
+ ? "Pressship package rules"
1064
+ : matcher.ignoredBy(relativePath);
1065
+ }
1066
+ function studioExplorerDirectoryIsAlwaysHidden(relativePath) {
1067
+ return relativePath
1068
+ .split("/")
1069
+ .filter(Boolean)
1070
+ .some((segment) => studioAlwaysHiddenDirectoryNames.has(segment));
1071
+ }
1072
+ function mergeStudioDirectoryEntries(entries) {
1073
+ const merged = new Map();
1074
+ for (const entry of entries) {
1075
+ const existing = merged.get(entry.path);
1076
+ if (!existing) {
1077
+ merged.set(entry.path, entry);
1078
+ continue;
1079
+ }
1080
+ merged.set(entry.path, {
1081
+ ...existing,
1082
+ ...entry,
1083
+ deferred: Boolean(existing.deferred || entry.deferred),
1084
+ ignored: Boolean(existing.ignored || entry.ignored),
1085
+ ignoredBy: existing.hardIgnored ? existing.ignoredBy : entry.ignoredBy ?? existing.ignoredBy,
1086
+ hardIgnored: Boolean(existing.hardIgnored || entry.hardIgnored)
1087
+ });
1088
+ }
1089
+ return Array.from(merged.values()).sort((a, b) => a.path.localeCompare(b.path));
1090
+ }
1091
+ async function readStudioIgnoreState(pluginPath) {
1092
+ const root = path.resolve(pluginPath);
1093
+ const patterns = await readPressshipIgnorePatterns(root);
1094
+ const ignoredFiles = await listStudioIgnoredFiles(root, patterns);
1095
+ return {
1096
+ ignorePath: path.join(root, pressshipIgnoreFile),
1097
+ patterns,
1098
+ ignoredFiles
1099
+ };
1100
+ }
1101
+ async function listStudioIgnoredFiles(root, patterns) {
1102
+ if (!patterns.length) {
1103
+ return [];
1104
+ }
1105
+ const matcher = createPressshipIgnoreMatcher(patterns);
1106
+ const files = await fg("**/*", {
760
1107
  cwd: root,
761
1108
  onlyFiles: true,
762
1109
  dot: false,
763
1110
  unique: true,
764
- ignore: [
765
- "**/.git/**",
766
- "**/.svn/**",
767
- "**/node_modules/**",
768
- "**/vendor/**",
769
- "**/build/**",
770
- "**/dist/**",
771
- "**/playground/**",
772
- "**/.wordpress-playground/**"
773
- ]
1111
+ ignore: studioHiddenFilePatterns
774
1112
  });
775
- const entries = await Promise.all(files.sort((a, b) => a.localeCompare(b)).map(async (relativePath) => {
1113
+ const ignored = files
1114
+ .map((relativePath) => ({ relativePath, ignoredBy: matcher.ignoredBy(relativePath) }))
1115
+ .filter((entry) => Boolean(entry.ignoredBy));
1116
+ const entries = await Promise.all(ignored.sort((a, b) => a.relativePath.localeCompare(b.relativePath)).map(async ({ relativePath, ignoredBy }) => {
776
1117
  const fileStats = await stat(path.join(root, relativePath));
777
1118
  return {
778
1119
  path: relativePath,
779
1120
  name: path.basename(relativePath),
780
1121
  directory: path.dirname(relativePath) === "." ? "" : path.dirname(relativePath),
781
- size: fileStats.size
1122
+ size: fileStats.size,
1123
+ ignoredBy
782
1124
  };
783
1125
  }));
784
- return { files: entries.filter((entry) => entry.size <= 1_000_000) };
1126
+ return entries;
1127
+ }
1128
+ function readStudioPackageSize(pluginPath, cache) {
1129
+ const source = resolvePluginProjectPath(pluginPath);
1130
+ const key = source.rootDir;
1131
+ const cached = cache.get(key);
1132
+ if (cached) {
1133
+ return publicStudioPackageSizeEntry(cached);
1134
+ }
1135
+ const entry = {
1136
+ status: "calculating",
1137
+ requestedAt: new Date().toISOString()
1138
+ };
1139
+ cache.set(key, entry);
1140
+ entry.promise = calculateStudioPackageSize(source.rootDir)
1141
+ .then((result) => {
1142
+ entry.status = "ready";
1143
+ entry.result = result;
1144
+ entry.error = undefined;
1145
+ entry.updatedAt = new Date().toISOString();
1146
+ })
1147
+ .catch((error) => {
1148
+ entry.status = "error";
1149
+ entry.error = error instanceof Error ? error.message : String(error);
1150
+ entry.updatedAt = new Date().toISOString();
1151
+ });
1152
+ return publicStudioPackageSizeEntry(entry);
1153
+ }
1154
+ function invalidateStudioPackageSize(pluginPath, cache) {
1155
+ cache.delete(resolvePluginProjectPath(pluginPath).rootDir);
1156
+ }
1157
+ async function calculateStudioPackageSize(rootDir) {
1158
+ const project = await discoverPluginProject(rootDir);
1159
+ const ignorePatterns = await readPressshipIgnorePatterns(rootDir);
1160
+ const analysis = await analyzePluginPackage(project, { ignore: ignorePatterns });
1161
+ return {
1162
+ sizeBytes: analysis.sizeBytes,
1163
+ maxSizeBytes: analysis.maxSizeBytes,
1164
+ overLimit: analysis.overLimit,
1165
+ fileCount: analysis.files.length,
1166
+ topLevelFolder: analysis.topLevelFolder,
1167
+ largestFiles: analysis.largestFiles
1168
+ };
1169
+ }
1170
+ function publicStudioPackageSizeEntry(entry) {
1171
+ if (entry.status === "ready" && entry.result) {
1172
+ return {
1173
+ status: "ready",
1174
+ cached: true,
1175
+ calculatedAt: entry.updatedAt,
1176
+ ...entry.result
1177
+ };
1178
+ }
1179
+ if (entry.status === "error") {
1180
+ return {
1181
+ status: "error",
1182
+ cached: true,
1183
+ error: entry.error ?? "Package size could not be calculated.",
1184
+ calculatedAt: entry.updatedAt
1185
+ };
1186
+ }
1187
+ return {
1188
+ status: "calculating",
1189
+ cached: false,
1190
+ requestedAt: entry.requestedAt
1191
+ };
785
1192
  }
786
1193
  async function readStudioFile(pluginPath, relativePath) {
787
1194
  const filePath = await resolveStudioFilePath(pluginPath, relativePath);
@@ -887,11 +1294,18 @@ async function resolveStudioFilePath(pluginPath, relativePath) {
887
1294
  if (!fileStats.isFile()) {
888
1295
  throw new Error("Studio can only open files.");
889
1296
  }
1297
+ if (fileStats.size > maxStudioEditableFileBytes) {
1298
+ throw new Error(`Studio can list ${normalized}, but files larger than 1 MB are not opened in the editor.`);
1299
+ }
890
1300
  return filePath;
891
1301
  }
892
1302
  function normalizeStudioRelativePath(relativePath) {
893
1303
  return relativePath.replace(/\\/g, "/").replace(/^\/+/, "").split("/").filter(Boolean).join("/");
894
1304
  }
1305
+ function isStudioEditablePath(relativePath) {
1306
+ const name = path.basename(relativePath).toLowerCase();
1307
+ return studioEditableFileNames.has(name) || studioEditableExtensions.has(path.extname(name));
1308
+ }
895
1309
  async function selectFolder() {
896
1310
  if (process.platform === "darwin") {
897
1311
  const result = await execa("osascript", [
@@ -1248,7 +1662,7 @@ async function createStudioAiPreviewWorkspace(pluginPath) {
1248
1662
  async function snapshotStudioFileContents(pluginPath) {
1249
1663
  const root = path.resolve(pluginPath);
1250
1664
  const { files } = await listStudioFiles(root);
1251
- const entries = await Promise.all(files.map(async (file) => {
1665
+ const entries = await Promise.all(files.filter((file) => file.size <= maxStudioEditableFileBytes && isStudioEditablePath(file.path)).map(async (file) => {
1252
1666
  const filePath = path.join(root, file.path);
1253
1667
  const [fileStats, content] = await Promise.all([stat(filePath), readFile(filePath, "utf8")]);
1254
1668
  return [
@@ -1358,18 +1772,26 @@ function streamJobEvents(response, jobs, id) {
1358
1772
  response.on("close", unsubscribe);
1359
1773
  }
1360
1774
  async function serveStatic(response, staticDir, requestPath, token) {
1361
- const filePath = path.join(staticDir, requestPath === "/" ? "index.html" : requestPath);
1362
- if (!filePath.startsWith(staticDir)) {
1775
+ if (requestPath === "/" || isSpaRoutePath(requestPath)) {
1776
+ await serveIndex(response, staticDir, token);
1777
+ return;
1778
+ }
1779
+ const rootDir = path.resolve(staticDir);
1780
+ const relativePath = requestPath.replace(/^\/+/, "");
1781
+ const filePath = path.resolve(rootDir, relativePath);
1782
+ if (filePath !== rootDir && !filePath.startsWith(`${rootDir}${path.sep}`)) {
1363
1783
  sendJson(response, 403, { error: { message: "Forbidden." } });
1364
1784
  return;
1365
1785
  }
1366
1786
  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));
1787
+ await serveIndex(response, rootDir, token);
1788
+ return;
1789
+ }
1790
+ try {
1791
+ await stat(filePath);
1792
+ }
1793
+ catch {
1794
+ sendJson(response, 404, { error: { message: "Not found." } });
1373
1795
  return;
1374
1796
  }
1375
1797
  const type = contentType(filePath);
@@ -1381,6 +1803,19 @@ async function serveStatic(response, staticDir, requestPath, token) {
1381
1803
  .on("error", () => sendJson(response, 404, { error: { message: "Not found." } }))
1382
1804
  .pipe(response);
1383
1805
  }
1806
+ function isSpaRoutePath(requestPath) {
1807
+ const firstSegment = requestPath.split("/").filter(Boolean)[0] ?? "";
1808
+ return spaRoutePrefixes.has(firstSegment);
1809
+ }
1810
+ async function serveIndex(response, staticDir, token) {
1811
+ const filePath = path.join(staticDir, "index.html");
1812
+ const html = await readFile(filePath, "utf8");
1813
+ response.writeHead(200, {
1814
+ "Cache-Control": "no-store",
1815
+ "Content-Type": "text/html; charset=utf-8"
1816
+ });
1817
+ response.end(html.replace("__PRESSSHIP_TOKEN__", token));
1818
+ }
1384
1819
  async function readJson(request) {
1385
1820
  const chunks = [];
1386
1821
  for await (const chunk of request) {