mirror of
https://github.com/webstudio-is/webstudio.git
synced 2026-10-24 20:17:07 +09:00
fix: Sync current projects and preserve imported asset folders
This commit is contained in:
1 parent
21015fce29
commit
a2a567551c
15 files changed
+489
-197
No files matched your search
+12
-2
@@ -96,7 +96,7 @@ Or link a Project non-interactively:
|
||||
npx webstudio link --link "<share-link-with-build-access>"
|
||||
```
|
||||
|
||||
Publish the Project in Webstudio Cloud before syncing when you need recent Builder changes in the local export.
|
||||
Sync exports the current saved Builder project and its asset files. Publishing is not required.
|
||||
|
||||
## Install Node.js only when needed
|
||||
|
||||
@@ -166,12 +166,22 @@ The share link should include Build access when you need to sync, build, import,
|
||||
|
||||
## Export and build a Project
|
||||
|
||||
After linking, publish the Project in Webstudio Cloud, then sync it locally:
|
||||
After linking, sync the current saved Project locally:
|
||||
|
||||
```bash
|
||||
npx webstudio sync
|
||||
```
|
||||
|
||||
Sync refreshes the project from the server, downloads its asset files, and prepares the local content index. It does not combine an older published build with current assets.
|
||||
|
||||
To replace another Project with the synced Project:
|
||||
|
||||
```bash
|
||||
npx webstudio import --to "<target-share-link-with-build-access>"
|
||||
```
|
||||
|
||||
Import replaces the target's pages, assets, and asset folders. It preserves file formats and folder assignments; it does not convert Markdown files to MDX.
|
||||
|
||||
Build a dynamic app with a deployment template:
|
||||
|
||||
```bash
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
{
|
||||
"bundleVersion": "bundle-1x6rafk",
|
||||
"build": {
|
||||
"id": "8f0fdff2-e76b-4f86-9203-ea7df5f1143c",
|
||||
"id": "823cc69d-b2ed-418b-823c-2efc717319cd",
|
||||
"projectId": "8a7358b1-7de3-459d-b7b1-56dddfb6ce1e",
|
||||
"version": 7,
|
||||
"createdAt": "2026-01-04T10:46:48.415+00:00",
|
||||
"updatedAt": "2026-01-04T10:46:48.415+00:00",
|
||||
"createdAt": "2024-09-03T04:31:42.776+00:00",
|
||||
"updatedAt": "2026-01-04T10:46:33.246+00:00",
|
||||
"pages": {
|
||||
"homePageId": "Y60VJnVZMU_ifI0GnAR1N",
|
||||
"rootFolderId": "root",
|
||||
@@ -147,12 +147,6 @@
|
||||
"projectSettings": {
|
||||
"meta": {},
|
||||
"compiler": {}
|
||||
},
|
||||
"deployment": {
|
||||
"destination": "saas",
|
||||
"domains": ["fixture-client-do-not-touch-wsmel"],
|
||||
"assetsDomain": "fixture-client-do-not-touch-wsmel",
|
||||
"excludeWstdDomainFromSearch": false
|
||||
}
|
||||
},
|
||||
"page": {
|
||||
@@ -202,9 +196,6 @@
|
||||
],
|
||||
"assets": [],
|
||||
"assetFolders": [],
|
||||
"user": {
|
||||
"email": "hello@webstudio.is"
|
||||
},
|
||||
"projectDomain": "fixture-client-do-not-touch-wsmel",
|
||||
"projectTitle": "FIXTURE-CLIENT-DO-NOT-TOUCH",
|
||||
"origin": "https://main.development.webstudio.is"
|
||||
|
||||
+1
-1
@@ -1,2 +1,2 @@
|
||||
export const assetQueryDeploymentId = "8f0fdff2-e76b-4f86-9203-ea7df5f1143c";
|
||||
export const assetQueryDeploymentId = "823cc69d-b2ed-418b-823c-2efc717319cd";
|
||||
export const assetQueryDatabase = undefined;
|
||||
+1
-1
@@ -43,5 +43,5 @@ export const getRemixParams = ({ ...params }: Params): Params => {
|
||||
}
|
||||
|
||||
|
||||
export const contactEmail = "hello@webstudio.is";
|
||||
export const contactEmail = undefined;
|
||||
|
||||
+1
-1
@@ -12,7 +12,7 @@
|
||||
|
||||
export const projectDomain = "fixture-client-do-not-touch-wsmel";
|
||||
|
||||
export const lastPublished = "2026-01-04T10:46:48.415Z";
|
||||
export const lastPublished = "2024-09-03T04:31:42.776Z";
|
||||
|
||||
export const siteName = undefined;
|
||||
|
||||
|
||||
@@ -43,5 +43,5 @@ export const getRemixParams = ({ ...params }: Params): Params => {
|
||||
}
|
||||
|
||||
|
||||
export const contactEmail = "hello@webstudio.is";
|
||||
export const contactEmail = undefined;
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
|
||||
export const projectDomain = "fixture-client-do-not-touch-wsmel";
|
||||
|
||||
export const lastPublished = "2026-01-04T10:46:48.415Z";
|
||||
export const lastPublished = "2024-09-03T04:31:42.776Z";
|
||||
|
||||
export const siteName = undefined;
|
||||
|
||||
|
||||
@@ -113,9 +113,9 @@ Once the project is linked, use the **`sync`** command to sync it with the cloud
|
||||
webstudio sync
|
||||
```
|
||||
|
||||
Make sure to publish the project in Webstudio Cloud before running the **`sync`** command in your local Webstudio project.
|
||||
Sync downloads the current saved Builder project and its asset files, then prepares the local content index. Publishing is not required.
|
||||
|
||||
`sync` is only for downloading a published project export. It is not part of the MCP editing workflow.
|
||||
Use the export with `webstudio build` or `webstudio import --to "<target-share-link>"`. Sync is not required for MCP editing.
|
||||
|
||||
### build
|
||||
|
||||
|
||||
@@ -26,7 +26,10 @@ import {
|
||||
import { migratePages } from "@webstudio-is/project-migrations/pages";
|
||||
import React from "react";
|
||||
import type { BuilderState } from "@webstudio-is/project-build/state";
|
||||
import { createBuilderStateFreshness } from "@webstudio-is/project-build/state";
|
||||
import {
|
||||
createBuilderStateFreshness,
|
||||
createBuilderBuildDataSnapshotFromState,
|
||||
} from "@webstudio-is/project-build/state";
|
||||
import type { BuilderRuntimeMutation } from "@webstudio-is/project-build/runtime";
|
||||
import type { BuilderPatchTransaction } from "@webstudio-is/project-build/contracts";
|
||||
import type { HighImpactFixture, EvaluationProject } from "./fixtures";
|
||||
@@ -350,8 +353,11 @@ export const startHighImpactFixtureApi = async (
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (operationPath === "build.loadProjectBundleByProjectId") {
|
||||
data = createLocalProjectBundleFromSessionSnapshot(
|
||||
if (
|
||||
operationPath === "build.loadProjectBundleByProjectId" ||
|
||||
operationPath === "build.loadData"
|
||||
) {
|
||||
const bundle = createLocalProjectBundleFromSessionSnapshot(
|
||||
{
|
||||
projectId,
|
||||
buildId,
|
||||
@@ -367,6 +373,21 @@ export const startHighImpactFixtureApi = async (
|
||||
},
|
||||
{ origin }
|
||||
);
|
||||
data =
|
||||
operationPath === "build.loadData"
|
||||
? {
|
||||
...bundle.build,
|
||||
...createBuilderBuildDataSnapshotFromState(state),
|
||||
pages: bundle.build.pages,
|
||||
assets: bundle.assets,
|
||||
assetFolders: bundle.assetFolders,
|
||||
project: {
|
||||
id: projectId,
|
||||
title: "High-impact evaluation",
|
||||
domain: "high-impact-evaluation",
|
||||
},
|
||||
}
|
||||
: bundle;
|
||||
} else if (operationPath === "projects.get") {
|
||||
data = {
|
||||
id: projectId,
|
||||
|
||||
@@ -607,7 +607,22 @@ const startFixtureApi = async (
|
||||
return undefined;
|
||||
}
|
||||
let data: unknown;
|
||||
if (operationPath === "build.loadProjectBundleByProjectId") {
|
||||
if (operationPath === "build.loadData") {
|
||||
const snapshot = getCurrentBuildSnapshot();
|
||||
data = {
|
||||
...getCurrentSerializedBuild(),
|
||||
...snapshot,
|
||||
pages: getCurrentSerializedBuild().pages,
|
||||
dataSources: snapshot.variables,
|
||||
assets: Array.from(state.assets?.values() ?? []),
|
||||
assetFolders: Array.from(state.assetFolders?.values() ?? []),
|
||||
project: {
|
||||
id: projectId,
|
||||
title: "Release smoke project",
|
||||
domain: "release-smoke",
|
||||
},
|
||||
};
|
||||
} else if (operationPath === "build.loadProjectBundleByProjectId") {
|
||||
data = { ...fixture.data, build: getCurrentSerializedBuild() };
|
||||
} else if (operationPath === "projects.get") {
|
||||
data = {
|
||||
|
||||
@@ -234,7 +234,7 @@ test("prepares preview by syncing missing data and generating the app template",
|
||||
downloadAssetFiles: vi.fn(async () => undefined),
|
||||
isFileExists: vi.fn(async () => true),
|
||||
loadProjectBundleByBuildId: vi.fn(),
|
||||
loadProjectBundleByProjectId: vi.fn(async () =>
|
||||
loadCurrentProjectBundle: vi.fn(async () =>
|
||||
createPublishedProjectBundleFixture()
|
||||
),
|
||||
readFile: vi.fn(),
|
||||
@@ -266,7 +266,7 @@ test("prepares preview by syncing missing data and generating the app template",
|
||||
ensureDependencies: vi.fn(async () => undefined),
|
||||
});
|
||||
|
||||
expect(syncDependencies.loadProjectBundleByProjectId).toHaveBeenCalled();
|
||||
expect(syncDependencies.loadCurrentProjectBundle).toHaveBeenCalled();
|
||||
expect(prebuildProject).toHaveBeenCalledWith({
|
||||
assets: true,
|
||||
template: ["react-router"],
|
||||
|
||||
@@ -2,6 +2,7 @@ import { afterEach, beforeEach, expect, test, vi } from "vitest";
|
||||
import { access, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
import { createServer } from "node:http";
|
||||
import { bundleVersion } from "@webstudio-is/protocol";
|
||||
import {
|
||||
createImageAssetFixture,
|
||||
@@ -9,7 +10,7 @@ import {
|
||||
} from "@webstudio-is/protocol/fixtures";
|
||||
import { createFileIfNotExists, isFileExists } from "../fs-utils";
|
||||
import { resolveApiConnection } from "../api-connection";
|
||||
import { sync } from "./sync";
|
||||
import { sync, defaultSyncDependencies } from "./sync";
|
||||
import { apiCompatibilityHeaders } from "./api";
|
||||
import { materializeManagedAgents } from "../managed-agents";
|
||||
|
||||
@@ -22,6 +23,7 @@ const indicator = {
|
||||
};
|
||||
const loadProjectBundleByBuildId = vi.fn();
|
||||
const loadProjectBundleByProjectId = vi.fn();
|
||||
const loadCurrentProjectBundle = vi.fn();
|
||||
const downloadAssetFiles = vi.fn();
|
||||
const dependencies = {
|
||||
createFileIfNotExists,
|
||||
@@ -29,6 +31,7 @@ const dependencies = {
|
||||
isFileExists,
|
||||
loadProjectBundleByBuildId,
|
||||
loadProjectBundleByProjectId,
|
||||
loadCurrentProjectBundle,
|
||||
readFile,
|
||||
resolveApiConnection,
|
||||
spinner: () => indicator,
|
||||
@@ -48,6 +51,7 @@ beforeEach(async () => {
|
||||
process.chdir(tempDir);
|
||||
loadProjectBundleByBuildId.mockResolvedValue(createProjectBundle());
|
||||
loadProjectBundleByProjectId.mockResolvedValue(createProjectBundle());
|
||||
loadCurrentProjectBundle.mockResolvedValue(createProjectBundle());
|
||||
downloadAssetFiles.mockResolvedValue(undefined);
|
||||
indicator.start.mockClear();
|
||||
indicator.message.mockClear();
|
||||
@@ -227,7 +231,7 @@ test("sends linked share token when synchronizing by build id", async () => {
|
||||
});
|
||||
});
|
||||
|
||||
test("explains unpublished project bundle errors when synchronizing linked project", async () => {
|
||||
test("syncs current editable data without requiring a published project", async () => {
|
||||
loadProjectBundleByProjectId.mockRejectedValue(
|
||||
Object.assign(new Error("Not published"), {
|
||||
data: { code: "NOT_FOUND", webstudioCode: "PROJECT_NOT_PUBLISHED" },
|
||||
@@ -239,19 +243,22 @@ test("explains unpublished project bundle errors when synchronizing linked proje
|
||||
projectId: "project-id",
|
||||
}));
|
||||
|
||||
await expect(
|
||||
sync({}, { ...dependencies, resolveApiConnection })
|
||||
).rejects.toThrow("Handled CLI error");
|
||||
|
||||
expect(indicator.stop).toHaveBeenCalledWith(
|
||||
[
|
||||
"Unable to synchronize project bundle because the project is not published.",
|
||||
"`webstudio sync` downloads the published project bundle.",
|
||||
"Publishing is not required for MCP editing. Do not ask the user to publish; use MCP tools against the latest editable build.",
|
||||
"For visual verification, use `preview.start` or `webstudio preview --source session` instead.",
|
||||
].join("\n"),
|
||||
2
|
||||
);
|
||||
const current = createProjectBundle({
|
||||
build: { version: 438 },
|
||||
assets: [createImageAssetFixture()],
|
||||
});
|
||||
loadCurrentProjectBundle.mockResolvedValue(current);
|
||||
await sync({}, { ...dependencies, resolveApiConnection });
|
||||
expect(loadProjectBundleByProjectId).not.toHaveBeenCalled();
|
||||
expect(loadCurrentProjectBundle).toHaveBeenCalledWith({
|
||||
authToken: "share-token",
|
||||
origin: "https://example.com",
|
||||
projectId: "project-id",
|
||||
headers: apiCompatibilityHeaders,
|
||||
});
|
||||
const data = JSON.parse(await readFile(".webstudio/data.json", "utf8"));
|
||||
expect(data.build.version).toBe(438);
|
||||
expect(data.assets).toEqual(current.assets);
|
||||
});
|
||||
|
||||
test("explains unpublished project bundle errors when synchronizing by build id", async () => {
|
||||
@@ -274,10 +281,8 @@ test("explains unpublished project bundle errors when synchronizing by build id"
|
||||
|
||||
expect(indicator.stop).toHaveBeenCalledWith(
|
||||
[
|
||||
"Unable to synchronize project bundle because the project is not published.",
|
||||
"`webstudio sync` downloads the published project bundle.",
|
||||
"Publishing is not required for MCP editing. Do not ask the user to publish; use MCP tools against the latest editable build.",
|
||||
"For visual verification, use `preview.start` or `webstudio preview --source session` instead.",
|
||||
"The selected build cannot be exported.",
|
||||
"Run `webstudio sync` without --buildId to export the current saved project without publishing.",
|
||||
].join("\n"),
|
||||
2
|
||||
);
|
||||
@@ -319,3 +324,102 @@ test("throws handled error when local project config is missing", async () => {
|
||||
2
|
||||
);
|
||||
});
|
||||
|
||||
test("repeated sync preserves current build metadata and file formats without publishing", async () => {
|
||||
const file = {
|
||||
...createImageAssetFixture(),
|
||||
type: "file" as const,
|
||||
name: "post.mdx",
|
||||
filename: "post",
|
||||
format: "mdx",
|
||||
meta: {},
|
||||
};
|
||||
const current = createProjectBundle({
|
||||
assets: [file],
|
||||
build: {
|
||||
version: 438,
|
||||
createdAt: "2026-07-30T23:21:20.362Z",
|
||||
updatedAt: "2026-09-01T21:44:30.648Z",
|
||||
},
|
||||
});
|
||||
const maps = [
|
||||
"breakpoints",
|
||||
"styles",
|
||||
"styleSources",
|
||||
"styleSourceSelections",
|
||||
"props",
|
||||
"instances",
|
||||
"dataSources",
|
||||
"resources",
|
||||
] as const;
|
||||
const rawBuild = {
|
||||
...current.build,
|
||||
...Object.fromEntries(
|
||||
maps.map((key) => [key, current.build[key].map(([, value]) => value)])
|
||||
),
|
||||
assets: current.assets,
|
||||
assetFolders: [],
|
||||
project: {
|
||||
id: current.build.projectId,
|
||||
title: current.projectTitle,
|
||||
domain: current.projectDomain,
|
||||
},
|
||||
};
|
||||
const requests: string[] = [];
|
||||
const tokens: Array<string | string[] | undefined> = [];
|
||||
const server = createServer((request, response) => {
|
||||
const url = new URL(request.url ?? "/", "http://localhost");
|
||||
requests.push(url.pathname);
|
||||
if (url.pathname === "/cgi/asset/post.mdx") {
|
||||
response.setHeader("content-type", "text/mdx");
|
||||
response.end("# Post");
|
||||
return;
|
||||
}
|
||||
tokens.push(request.headers["x-auth-token"]);
|
||||
response.setHeader("content-type", "application/json");
|
||||
if (url.pathname !== "/trpc/build.loadData") {
|
||||
response.writeHead(404);
|
||||
response.end(JSON.stringify({ error: "unexpected endpoint" }));
|
||||
return;
|
||||
}
|
||||
response.end(JSON.stringify([{ result: { data: rawBuild } }]));
|
||||
});
|
||||
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
|
||||
try {
|
||||
const address = server.address();
|
||||
if (address === null || typeof address === "string") {
|
||||
throw new Error("Missing test server address");
|
||||
}
|
||||
const connection = {
|
||||
origin: `http://127.0.0.1:${address.port}`,
|
||||
projectId: current.build.projectId,
|
||||
authToken: "source-token",
|
||||
};
|
||||
const realDependencies = {
|
||||
...defaultSyncDependencies,
|
||||
resolveApiConnection: async () => connection,
|
||||
spinner: () => indicator,
|
||||
};
|
||||
await sync({}, realDependencies);
|
||||
const first = await readFile(".webstudio/data.json", "utf8");
|
||||
await sync({}, realDependencies);
|
||||
expect(await readFile(".webstudio/data.json", "utf8")).toBe(first);
|
||||
expect(JSON.parse(first)).toMatchObject({
|
||||
build: current.build,
|
||||
projectTitle: current.projectTitle,
|
||||
projectDomain: current.projectDomain,
|
||||
assets: [file],
|
||||
});
|
||||
expect(await readFile(".webstudio/assets/post.mdx", "utf8")).toBe("# Post");
|
||||
expect(requests).toEqual([
|
||||
"/trpc/build.loadData",
|
||||
"/cgi/asset/post.mdx",
|
||||
"/trpc/build.loadData",
|
||||
]);
|
||||
expect(tokens).toEqual(["source-token", "source-token"]);
|
||||
} finally {
|
||||
await new Promise<void>((resolve, reject) =>
|
||||
server.close((error) => (error ? reject(error) : resolve()))
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -2,11 +2,15 @@ import { readFile, writeFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { cwd } from "node:process";
|
||||
import { spinner } from "@clack/prompts";
|
||||
import { type PublishedProjectBundle } from "@webstudio-is/protocol";
|
||||
import {
|
||||
bundleVersion,
|
||||
publishedProjectBundle,
|
||||
type PublishedProjectBundle,
|
||||
} from "@webstudio-is/protocol";
|
||||
import {
|
||||
getApiErrorCode,
|
||||
loadProjectBundleByBuildId,
|
||||
loadProjectBundleByProjectId,
|
||||
loadBuilderDataByProjectId,
|
||||
toLocalProjectBundle,
|
||||
} from "@webstudio-is/http-client";
|
||||
import { createFileIfNotExists, isFileExists } from "../fs-utils";
|
||||
@@ -20,13 +24,77 @@ import { apiCompatibilityHeaders, stopSpinnerWithError } from "./api";
|
||||
import { downloadAssetFiles } from "../asset-files";
|
||||
import { resolveApiConnection } from "../api-connection";
|
||||
import { materializeManagedAgents } from "../managed-agents";
|
||||
import { serializedBuild } from "@webstudio-is/project-build/contracts";
|
||||
import {
|
||||
createBuilderStateFromBuildData,
|
||||
createSerializedBuilderBuildDataFromState,
|
||||
} from "@webstudio-is/project-build/state";
|
||||
import { migratePages } from "@webstudio-is/project-migrations/pages";
|
||||
import {
|
||||
createReachableAssetContentCompilationPlan,
|
||||
getHomePage,
|
||||
} from "@webstudio-is/sdk";
|
||||
import { compileContentSource } from "@webstudio-is/content-engine/compiler";
|
||||
import { parseContentDatabaseMaxBytes } from "@webstudio-is/content-engine";
|
||||
import { createFileSystemContentSource } from "../filesystem-content-source";
|
||||
import { z } from "zod";
|
||||
import {
|
||||
breakpoint,
|
||||
styleDecl,
|
||||
styleSource,
|
||||
styleSourceSelection,
|
||||
prop,
|
||||
instance,
|
||||
dataSource,
|
||||
resource,
|
||||
} from "@webstudio-is/sdk/schema";
|
||||
|
||||
// Builder data uses arrays rather than the keyed pairs in an export bundle.
|
||||
const builderData = serializedBuild.extend({
|
||||
breakpoints: breakpoint.array(),
|
||||
styles: styleDecl.array(),
|
||||
styleSources: styleSource.array(),
|
||||
styleSourceSelections: styleSourceSelection.array(),
|
||||
props: prop.array(),
|
||||
instances: instance.array(),
|
||||
dataSources: dataSource.array(),
|
||||
resources: resource.array(),
|
||||
assets: publishedProjectBundle.shape.assets,
|
||||
assetFolders: publishedProjectBundle.shape.assetFolders,
|
||||
project: z.object({ id: z.string(), title: z.string(), domain: z.string() }),
|
||||
});
|
||||
|
||||
const loadCurrentProjectBundle = async (
|
||||
connection: Awaited<ReturnType<typeof resolveApiConnection>>
|
||||
) => {
|
||||
const data = builderData.parse(await loadBuilderDataByProjectId(connection));
|
||||
if (
|
||||
data.projectId !== connection.projectId ||
|
||||
data.project.id !== connection.projectId
|
||||
) {
|
||||
throw new Error("Source project does not match the linked project");
|
||||
}
|
||||
const pages = migratePages(data.pages);
|
||||
const state = createBuilderStateFromBuildData({ ...data, pages });
|
||||
return publishedProjectBundle.parse({
|
||||
bundleVersion,
|
||||
origin: connection.origin,
|
||||
projectDomain: data.project.domain,
|
||||
projectTitle: data.project.title,
|
||||
page: getHomePage(pages),
|
||||
pages: Array.from(pages.pages.values()),
|
||||
assets: data.assets,
|
||||
assetFolders: data.assetFolders,
|
||||
build: { ...data, ...createSerializedBuilderBuildDataFromState(state) },
|
||||
});
|
||||
};
|
||||
|
||||
export type SyncDependencies = {
|
||||
createFileIfNotExists: typeof createFileIfNotExists;
|
||||
downloadAssetFiles: typeof downloadAssetFiles;
|
||||
isFileExists: typeof isFileExists;
|
||||
loadProjectBundleByBuildId: typeof loadProjectBundleByBuildId;
|
||||
loadProjectBundleByProjectId: typeof loadProjectBundleByProjectId;
|
||||
loadCurrentProjectBundle: typeof loadCurrentProjectBundle;
|
||||
readFile: typeof readFile;
|
||||
resolveApiConnection: typeof resolveApiConnection;
|
||||
spinner: typeof spinner;
|
||||
@@ -39,7 +107,7 @@ export const defaultSyncDependencies: SyncDependencies = {
|
||||
downloadAssetFiles,
|
||||
isFileExists,
|
||||
loadProjectBundleByBuildId,
|
||||
loadProjectBundleByProjectId,
|
||||
loadCurrentProjectBundle,
|
||||
readFile,
|
||||
resolveApiConnection,
|
||||
spinner,
|
||||
@@ -65,10 +133,8 @@ export const syncOptions = (yargs: CommonYargsArgv) =>
|
||||
type SyncOptions = Partial<StrictYargsOptionsToInterface<typeof syncOptions>>;
|
||||
|
||||
const unpublishedProjectBundleMessage = [
|
||||
"Unable to synchronize project bundle because the project is not published.",
|
||||
"`webstudio sync` downloads the published project bundle.",
|
||||
"Publishing is not required for MCP editing. Do not ask the user to publish; use MCP tools against the latest editable build.",
|
||||
"For visual verification, use `preview.start` or `webstudio preview --source session` instead.",
|
||||
"The selected build cannot be exported.",
|
||||
"Run `webstudio sync` without --buildId to export the current saved project without publishing.",
|
||||
].join("\n");
|
||||
|
||||
const isUnpublishedProjectBundleError = (error: unknown) => {
|
||||
@@ -146,7 +212,7 @@ export const sync = async (
|
||||
origin,
|
||||
headers: apiCompatibilityHeaders,
|
||||
})
|
||||
: await dependencies.loadProjectBundleByProjectId({
|
||||
: await dependencies.loadCurrentProjectBundle({
|
||||
projectId,
|
||||
authToken,
|
||||
origin,
|
||||
@@ -185,6 +251,32 @@ export const sync = async (
|
||||
}
|
||||
}
|
||||
|
||||
if (options.buildId === undefined) {
|
||||
const plan = createReachableAssetContentCompilationPlan({
|
||||
props: project.build.props.map(([, value]) => value),
|
||||
dataSources: project.build.dataSources.map(([, value]) => value),
|
||||
resources: project.build.resources.map(([, value]) => value),
|
||||
});
|
||||
if (plan !== undefined) {
|
||||
syncing.message("Preparing local content index");
|
||||
const { artifact } = await compileContentSource({
|
||||
source: createFileSystemContentSource({
|
||||
projectId: project.build.projectId,
|
||||
assets: project.assets,
|
||||
folders: new Map(
|
||||
(project.assetFolders ?? []).map((folder) => [folder.id, folder])
|
||||
),
|
||||
}),
|
||||
projectId: project.build.projectId,
|
||||
plan,
|
||||
maxBytes: parseContentDatabaseMaxBytes(
|
||||
process.env.CONTENT_DATABASE_MAX_BYTES
|
||||
),
|
||||
});
|
||||
project.assetIndex = artifact;
|
||||
}
|
||||
}
|
||||
|
||||
const localBuildFilePath = join(cwd(), LOCAL_DATA_FILE);
|
||||
await dependencies.createFileIfNotExists(localBuildFilePath);
|
||||
await dependencies.writeFile(
|
||||
|
||||
@@ -2183,148 +2183,193 @@ test("imports project bundle through staged upload", async () => {
|
||||
expect(JSON.stringify(trpcBody)).not.toContain("largeContent");
|
||||
});
|
||||
|
||||
test("imports project bundle with assets and retries missing asset uploads", async () => {
|
||||
const asset = createImageAssetFixture({ name: "image.png" });
|
||||
const bundle = createPublishedProjectBundleFixture({ assets: [asset] });
|
||||
const calls: string[] = [];
|
||||
const uploadUrls: string[] = [];
|
||||
const uploadOffsets = new Map<string, number>();
|
||||
let importAttempts = 0;
|
||||
let uploadAttempts = 0;
|
||||
|
||||
const server = createServer(async (request, response) => {
|
||||
const url = new URL(request.url ?? "/", "http://localhost");
|
||||
calls.push(`${request.method} ${url.pathname}`);
|
||||
|
||||
if (
|
||||
request.method === "GET" &&
|
||||
url.pathname === "/trpc/build.checkProjectBuildPermission"
|
||||
) {
|
||||
response.writeHead(200, { "content-type": "application/json" });
|
||||
response.end(JSON.stringify([{ result: { data: undefined } }]));
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
request.method === "POST" &&
|
||||
url.pathname === "/rest/assets/uploads/image.png"
|
||||
) {
|
||||
uploadAttempts += 1;
|
||||
uploadUrls.push(url.href);
|
||||
await readRequestBody(request);
|
||||
response.writeHead(200, { "content-type": "application/json" });
|
||||
response.end(
|
||||
JSON.stringify({
|
||||
uploadedAssets: [
|
||||
createImageAssetFixture({
|
||||
id: `server-generated-asset-${uploadAttempts}`,
|
||||
name: "image_destination.png",
|
||||
}),
|
||||
],
|
||||
deduplicated: false,
|
||||
})
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (request.method === "POST" && url.pathname === stagedUploadPath) {
|
||||
response.writeHead(201, {
|
||||
Location: `http://${request.headers.host}${stagedUploadPath}/upload-id-${importAttempts}`,
|
||||
"Tus-Resumable": "1.0.0",
|
||||
});
|
||||
response.end();
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
request.method === "PATCH" &&
|
||||
url.pathname.startsWith(`${stagedUploadPath}/upload-id-`)
|
||||
) {
|
||||
const chunk = await readRequestBody(request);
|
||||
const offset = (uploadOffsets.get(url.pathname) ?? 0) + chunk.byteLength;
|
||||
uploadOffsets.set(url.pathname, offset);
|
||||
response.writeHead(204, {
|
||||
"Tus-Resumable": "1.0.0",
|
||||
"Upload-Offset": String(offset),
|
||||
});
|
||||
response.end();
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
request.method === "POST" &&
|
||||
url.pathname === "/trpc/build.importProjectBundle"
|
||||
) {
|
||||
importAttempts += 1;
|
||||
await readRequestBody(request);
|
||||
response.writeHead(200, { "content-type": "application/json" });
|
||||
response.end(
|
||||
JSON.stringify([
|
||||
importAttempts === 1
|
||||
? {
|
||||
error: {
|
||||
message: "Imported asset files are missing: image.png",
|
||||
code: -32603,
|
||||
data: { code: "INTERNAL_SERVER_ERROR" },
|
||||
},
|
||||
}
|
||||
: { result: { data: { version: 2 } } },
|
||||
])
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
response.writeHead(404);
|
||||
response.end();
|
||||
});
|
||||
|
||||
await new Promise<void>((resolve) => {
|
||||
server.listen(0, "127.0.0.1", resolve);
|
||||
});
|
||||
|
||||
const importAttemptMessages: string[] = [];
|
||||
const missingAssetMessages: string[] = [];
|
||||
const uploadAssetMessages: string[] = [];
|
||||
|
||||
try {
|
||||
const address = server.address();
|
||||
if (address === null || typeof address === "string") {
|
||||
throw new Error("Server address is unavailable");
|
||||
}
|
||||
|
||||
await expect(
|
||||
importProjectBundleWithAssets({
|
||||
authToken: "token",
|
||||
origin: `http://127.0.0.1:${address.port}`,
|
||||
projectId: "project-id",
|
||||
data: bundle,
|
||||
readAssetData: async () => new Blob(["asset"]),
|
||||
onImportAttempt: () => importAttemptMessages.push("attempt"),
|
||||
onMissingAssets: (assets) =>
|
||||
missingAssetMessages.push(
|
||||
assets.map((asset) => asset.name).join(",")
|
||||
),
|
||||
onUploadAssets: (assets) =>
|
||||
uploadAssetMessages.push(assets.map((asset) => asset.name).join(",")),
|
||||
})
|
||||
).resolves.toEqual({ version: 2 });
|
||||
} finally {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
server.close((error) => (error ? reject(error) : resolve()));
|
||||
test.each([undefined, "source-folder"])(
|
||||
"imports assets from folder %s and preserves their final placement",
|
||||
async (folderId) => {
|
||||
const asset = createImageAssetFixture({ name: "image.png", folderId });
|
||||
const assetFolders =
|
||||
folderId === undefined
|
||||
? []
|
||||
: [
|
||||
{
|
||||
id: folderId,
|
||||
name: "Images",
|
||||
projectId: "source-project",
|
||||
createdAt: "2026-09-09T00:00:00.000Z",
|
||||
},
|
||||
];
|
||||
const bundle = createPublishedProjectBundleFixture({
|
||||
assets: [asset],
|
||||
assetFolders,
|
||||
});
|
||||
}
|
||||
const uploadChunks = new Map<string, Buffer[]>();
|
||||
const readAssets: unknown[] = [];
|
||||
const calls: string[] = [];
|
||||
const uploadUrls: string[] = [];
|
||||
const uploadOffsets = new Map<string, number>();
|
||||
let importAttempts = 0;
|
||||
let uploadAttempts = 0;
|
||||
|
||||
expect(calls).toContain("GET /trpc/build.checkProjectBuildPermission");
|
||||
expect(importAttempts).toBe(2);
|
||||
expect(uploadAttempts).toBe(1);
|
||||
expect(uploadUrls.every((url) => url.includes("assetId=") === false)).toBe(
|
||||
true
|
||||
);
|
||||
expect(uploadOffsets.size).toBe(2);
|
||||
expect(importAttemptMessages).toEqual(["attempt", "attempt"]);
|
||||
expect(missingAssetMessages).toEqual([]);
|
||||
expect(uploadAssetMessages).toEqual(["image.png"]);
|
||||
});
|
||||
const server = createServer(async (request, response) => {
|
||||
const url = new URL(request.url ?? "/", "http://localhost");
|
||||
calls.push(`${request.method} ${url.pathname}`);
|
||||
|
||||
if (
|
||||
request.method === "GET" &&
|
||||
url.pathname === "/trpc/build.checkProjectBuildPermission"
|
||||
) {
|
||||
response.writeHead(200, { "content-type": "application/json" });
|
||||
response.end(JSON.stringify([{ result: { data: undefined } }]));
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
request.method === "POST" &&
|
||||
url.pathname === "/rest/assets/uploads/image.png"
|
||||
) {
|
||||
uploadAttempts += 1;
|
||||
uploadUrls.push(url.href);
|
||||
await readRequestBody(request);
|
||||
response.writeHead(200, { "content-type": "application/json" });
|
||||
response.end(
|
||||
JSON.stringify({
|
||||
uploadedAssets: [
|
||||
createImageAssetFixture({
|
||||
id: `server-generated-asset-${uploadAttempts}`,
|
||||
name: "image_destination.png",
|
||||
}),
|
||||
],
|
||||
deduplicated: false,
|
||||
})
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (request.method === "POST" && url.pathname === stagedUploadPath) {
|
||||
response.writeHead(201, {
|
||||
Location: `http://${request.headers.host}${stagedUploadPath}/upload-id-${importAttempts}`,
|
||||
"Tus-Resumable": "1.0.0",
|
||||
});
|
||||
response.end();
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
request.method === "PATCH" &&
|
||||
url.pathname.startsWith(`${stagedUploadPath}/upload-id-`)
|
||||
) {
|
||||
const chunk = await readRequestBody(request);
|
||||
const chunks = uploadChunks.get(url.pathname) ?? [];
|
||||
chunks.push(chunk);
|
||||
uploadChunks.set(url.pathname, chunks);
|
||||
const offset =
|
||||
(uploadOffsets.get(url.pathname) ?? 0) + chunk.byteLength;
|
||||
uploadOffsets.set(url.pathname, offset);
|
||||
response.writeHead(204, {
|
||||
"Tus-Resumable": "1.0.0",
|
||||
"Upload-Offset": String(offset),
|
||||
});
|
||||
response.end();
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
request.method === "POST" &&
|
||||
url.pathname === "/trpc/build.importProjectBundle"
|
||||
) {
|
||||
importAttempts += 1;
|
||||
await readRequestBody(request);
|
||||
response.writeHead(200, { "content-type": "application/json" });
|
||||
response.end(
|
||||
JSON.stringify([
|
||||
importAttempts === 1
|
||||
? {
|
||||
error: {
|
||||
message: "Imported asset files are missing: image.png",
|
||||
code: -32603,
|
||||
data: { code: "INTERNAL_SERVER_ERROR" },
|
||||
},
|
||||
}
|
||||
: { result: { data: { version: 2 } } },
|
||||
])
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
response.writeHead(404);
|
||||
response.end();
|
||||
});
|
||||
|
||||
await new Promise<void>((resolve) => {
|
||||
server.listen(0, "127.0.0.1", resolve);
|
||||
});
|
||||
|
||||
const importAttemptMessages: string[] = [];
|
||||
const missingAssetMessages: string[] = [];
|
||||
const uploadAssetMessages: string[] = [];
|
||||
|
||||
try {
|
||||
const address = server.address();
|
||||
if (address === null || typeof address === "string") {
|
||||
throw new Error("Server address is unavailable");
|
||||
}
|
||||
|
||||
await expect(
|
||||
importProjectBundleWithAssets({
|
||||
authToken: "token",
|
||||
origin: `http://127.0.0.1:${address.port}`,
|
||||
projectId: "project-id",
|
||||
data: bundle,
|
||||
readAssetData: async (asset) => {
|
||||
readAssets.push(asset);
|
||||
return new Blob(["asset"]);
|
||||
},
|
||||
onImportAttempt: () => importAttemptMessages.push("attempt"),
|
||||
onMissingAssets: (assets) =>
|
||||
missingAssetMessages.push(
|
||||
assets.map((asset) => asset.name).join(",")
|
||||
),
|
||||
onUploadAssets: (assets) =>
|
||||
uploadAssetMessages.push(
|
||||
assets.map((asset) => asset.name).join(",")
|
||||
),
|
||||
})
|
||||
).resolves.toEqual({ version: 2 });
|
||||
} finally {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
server.close((error) => (error ? reject(error) : resolve()));
|
||||
});
|
||||
}
|
||||
|
||||
expect(calls).toContain("GET /trpc/build.checkProjectBuildPermission");
|
||||
expect(importAttempts).toBe(2);
|
||||
expect(uploadAttempts).toBe(1);
|
||||
expect(
|
||||
uploadUrls.map((url) => new URL(url).searchParams.get("folderId"))
|
||||
).toEqual([null]);
|
||||
expect(readAssets).toHaveLength(1);
|
||||
expect(readAssets[0]).toBe(asset);
|
||||
expect(bundle.assets[0]).toEqual(asset);
|
||||
const importedBundles = Array.from(uploadChunks.values(), (chunks) =>
|
||||
JSON.parse(Buffer.concat(chunks).toString("utf8"))
|
||||
);
|
||||
expect(importedBundles.map((bundle) => bundle.assetFolders)).toEqual([
|
||||
assetFolders,
|
||||
assetFolders,
|
||||
]);
|
||||
expect(importedBundles.map((bundle) => bundle.assets)).toEqual([
|
||||
[asset],
|
||||
[{ ...asset, name: "image_destination.png" }],
|
||||
]);
|
||||
expect(uploadUrls.every((url) => url.includes("assetId=") === false)).toBe(
|
||||
true
|
||||
);
|
||||
expect(uploadOffsets.size).toBe(2);
|
||||
expect(importAttemptMessages).toEqual(["attempt", "attempt"]);
|
||||
expect(missingAssetMessages).toEqual([]);
|
||||
expect(uploadAssetMessages).toEqual(["image.png"]);
|
||||
}
|
||||
);
|
||||
|
||||
test("rejects project bundles over the import size limit", async () => {
|
||||
let requestCount = 0;
|
||||
|
||||
@@ -1271,6 +1271,11 @@ export const getProjectInfo = projectQuery(
|
||||
getPublicApiOperationPath("inspect")
|
||||
);
|
||||
|
||||
export const loadBuilderDataByProjectId = (params: AuthProjectParams) =>
|
||||
createAuthTrpcClient(params).query("build.loadData", {
|
||||
projectId: params.projectId,
|
||||
});
|
||||
|
||||
export const getProjectPermissions = projectQuery(
|
||||
getPublicApiOperationPath("permissions")
|
||||
);
|
||||
@@ -2418,10 +2423,19 @@ export const importProjectBundleWithAssets = async (
|
||||
} else {
|
||||
params.onMissingAssets?.(missingAssets);
|
||||
}
|
||||
const originalAssetsById = new Map(
|
||||
missingAssets.map((asset) => [asset.id, asset])
|
||||
);
|
||||
const uploadedAssets = await uploadAssets({
|
||||
...params,
|
||||
assets: missingAssets,
|
||||
readAssetData,
|
||||
// Source folders do not exist in the destination yet. The final
|
||||
// bundle import creates them and restores the original assignments.
|
||||
assets: missingAssets.map((asset) => ({
|
||||
...asset,
|
||||
folderId: undefined,
|
||||
})),
|
||||
readAssetData: (asset) =>
|
||||
readAssetData(originalAssetsById.get(asset.id)!),
|
||||
});
|
||||
if (uploadedAssets.length !== missingAssets.length) {
|
||||
throw new Error(
|
||||
|
||||
Reference in new issue
Block a user