Skip to content

fix: generate portals, SDKs and plugins from projects with a large static/ folder - #457

Merged
saeedjamshaid merged 4 commits into
devfrom
saeedjamshaid/upload-without-static
Oct 27, 2026
Merged

saeedjamshaid merged 4 commits into
devfrom
saeedjamshaid/upload-without-static

Conversation

@saeedjamshaid

@saeedjamshaid saeedjamshaid commented Oct 27, 2026 •

Copy link
Copy Markdown
Collaborator

portal generate, portal serve (and so quickstart), sdk generate (and so sdk publish) and plugin generate no longer upload src/static/ with the build. Nothing on the server reads it, and the portal build still copies it from disk. content/ is still uploaded.

A project whose static/ folder made the upload too large, such as one with a few large GIFs, used to fail with "An unexpected error occurred". It now generates.

What changed

  • One build upload for all three commands. ProjectContext makes it: buildZip for the portal and the SDK, and pluginBuildZip for the plugin, which also narrows the copy's languages. Each copies src/ to a temporary folder without static/, then zips the copy. So a large static/ is no longer copied to disk either.
  • static/ is matched in any letter case. On Windows and macOS the portal build reads a Static/ folder as static/, so that folder is left out too.
  • The copy holds what links point to. A linked folder in src/ used to fail every upload, and a relative file link broke in the copy that sdk generate and plugin generate made. Both are now uploaded as what they point to, the way the portal build reads them.
  • A build that can't be prepared says why. All three commands now report "'…/src' could not be zipped for upload: ", for example when a file is locked. Before, portal generate and portal serve showed "An unexpected error occurred, please try again later", and sdk generate and plugin generate crashed with a raw EBUSY error. No partial copy is left behind in the temporary folder.
  • The portal's build is sent like the SDK and plugin builds: as a file named by a random ID, of type application/octet-stream, rather than build.zip / application/zip. Production accepts it.

How it was verified

  • Tests pin each part: the folders left out (any case, top level only), the links followed, a failed copy reported only once the copy has stopped, and the upload contents of each command. Each was checked by breaking the code and seeing a test fail.

  • The full suite passes with APIMATIC_E2E=1.

  • Real runs against production, against 2.0.0:

    Project 2.0.0 This branch
    23 MB in static/: portal generate, sdk generate, plugin generate "An unexpected error occurred" all three succeed; portal serve serves the files
    22 MB in Static/: portal generate not run succeeds, and the files are published
    A locked file in src/ portal: generic message; sdk and plugin: raw EBUSY crash all three name the file and the reason
    A small project with static/: plugin generate 69 files byte-identical, 69 files

Known edge

A link inside src/ that points back at src/ or a folder above it is followed about 63 levels deep before the copy fails with ELOOP. That failure is reported with the same message. Before, such a link failed straight away.

A clearer message for an upload that is still too large follows separately.

Refs apimatic/apimatic-io#2316

🤖 Generated with Claude Code

No server step reads it, and an image-heavy static/ pushed the upload past the size limit.
portal generate now zips src/ in the action, as sdk and plugin generate do, so a failed zip
names its reason instead of the generic error.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Sonar counted the separate test as duplicated lines, over the 3% gate for new code.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

@mehnoorsiddiqui mehnoorsiddiqui left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Tested this end to end and reviewed it for code quality. The fix works, and the zip-failure message is a real improvement. Inline comments are mostly design and maintainability points. None of them block the fix.

What I verified

  • pnpm build, pnpm test (1970 passing) and the end-to-end tests with APIMATIC_E2E=1 (74 passing) all pass.
  • Production, with 26 MB of random data in static/: on dev, portal generate and sdk generate fail with "An unexpected error occurred". On this branch both succeed, and the GIFs and logo land in the portal byte for byte. portal serve serves them too.
  • Same small project on dev and on this branch:
    • The portal generate output is identical apart from build timestamps.
    • The SDK zip inside the portal and the SDK from sdk generate are identical.
  • Branded fixture (logo and favicon in static/): builds, and the server doesn't need them uploaded.
  • Locked file in src/: dev shows the generic message; this branch names the folder and the EBUSY reason.
  • plugin generate: not fully verified. My account isn't entitled to plugin generation. This branch reached the entitlement 403 in 10 s, where dev failed on the upload after about 2 min, so the upload goes through, but I couldn't compare plugin output.

General points

  1. Plugin scope. The issue (apimatic-io#2316) proposed excluding static/ from the /portal-artifacts call only, and listed context-plugin generation as an open question. This PR also excludes it from plugin generate. SDK output checks out (see above), but nobody has confirmed plugin output yet, so "plugins are unchanged" in the description is ahead of the evidence. Could someone with plugin access diff a plugin built with and without static/?
  2. static/ is still copied, just not uploaded. srcDirZip (project-context.ts:120) and stageUpload (plugin-config-context.ts:218) copy all of src/ into temp before zipping. A large static/ still costs a full disk copy. A locked file in static/ still crashes sdk generate with a raw Error: EBUSY ... copyfile; I reproduced this, and dev does the same. Skipping static/ at copy time would fix both. That fits naturally with the first inline comment.
  3. Upload part changed. The portal build now goes up as filename="<uuid>" with application/octet-stream instead of build.zip / application/zip. Production accepts it. The description calls it an "unnamed file"; it actually carries a UUID name.
  4. Test gaps.
    • sdk generate has no action-level test that static/ is left out; it relies on the srcDirZip unit test.
    • Nothing pins case sensitivity or how nested paths in excluding are matched.
    • new AdmZip(...).getEntries().map((e) => e.entryName) now appears in three test files and could be a shared helper.

The 413 mapping (part 2 of the issue) is understood to follow separately.

🤖 Generated with Claude Code

Comment thread src/types/temp-context.ts Outdated
return await this.zipWithout(directory, []);
}

public async zipBuild(directory: DirectoryPath): Promise<Result<FilePath, FileProblem>> {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The rule for what a build upload contains is now spread across three call sites:

  • PreparePortalProjectAction builds a TempContext and zips the raw source path itself.
  • sdk generate goes through ProjectContext.srcDirZip.
  • plugin generate calls stageUpload and then zipBuild.

TempContext used to be a generic temp-file helper. With this it also knows the src/ layout, via project-layout.

The risk: the next upload site calls zip() instead of zipBuild(), and static/ quietly goes up again. Could the build be one concept on ProjectContext, say buildZip(tempDirectory, …)? All three callers would use it, static/ could be skipped at copy time (general point 2), and TempContext would stay layout-agnostic. That matches the "one domain concept per context" rule in .ai/skills/context.md.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done. ProjectContext.buildZip (portal, sdk) and pluginBuildZip (plugin) now make every build upload. Both copy src/ without static/ through one private step, and TempContext and ZipService are back to what they are on dev.

Comment thread src/actions/portal/prepare-project.ts Outdated
// The artifacts live in this directory for as long as the caller needs them, so it wraps
// everything that reads them rather than being opened and closed around the call.
return await withDirPath(async (artifactsDirectory) => {
const build = await new TempContext(artifactsDirectory).zipBuild(sourceDirectory);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice: checking this early and reporting the real reason is much better than the old ServiceError.InvalidResponse. Small design note: the action now builds a TempContext and zips project.sourceDirectory() directly. If the build zip moved onto ProjectContext (see the comment on temp-context.ts), this would become project.buildZip(artifactsDirectory), and the action wouldn't handle the raw path.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done: the action calls project.buildZip(artifactsDirectory) and no longer handles the source path.

Comment thread src/infrastructure/zip-service.ts Outdated
return fs.readdirSync(dir.toString(), { withFileTypes: true }).flatMap((entry) => {
// Always use forward slashes as metadataPath — zip format requires it
const metadataPath = relativePrefix ? `${relativePrefix}/${entry.name}` : entry.name;
if (excluding.includes(metadataPath)) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

excluding is a list of plain strings matched against the forward-slash relative path. That has two consequences:

  • Not really "top-level only": the doc comment says top-level entries, but 'content/static' would also match, and a path with backslashes never would.
  • Case-sensitive: a Static/ folder is still uploaded. On Windows and macOS the local build may still pick it up as static/ (I haven't verified that), so those users could hit the old size failure.

Could the parameter say what it means, e.g. excludingTopLevel: readonly FileName[], compared against entry.name only when relativePrefix === ''? It would then match its doc comment, and the "no raw string paths" rule would be respected.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The rule moved to the copy, where it matches top-level names only and ignores case (FileName.is), so a Static/ folder is left out too. On Windows, a project with 22 MB in Static/ now generates, and its files are still published.

Comment thread src/prompts/upload.ts Outdated
import { DirectoryPath } from '../types/file/directoryPath.js';
import { format as f } from './format.js';

export const notZippedForUpload = (directory: DirectoryPath, reason: string): string =>

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

.ai/skills/prompt.md (location table) puts shared prompt utilities in src/prompts/prompt.ts. Should this live there instead of in a new one-function module?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Moved notZippedForUpload into src/prompts/prompt.ts, and upload.ts is gone.

Comment thread src/prompts/sdk/publish.ts Outdated

public sdkNotZipped(sdkDirectory: DirectoryPath, problem: FileProblem) {
log.error(`The SDK in ${f.path(sdkDirectory)} could not be zipped for upload: ${problem.reason}`);
log.error(`The SDK in ${notZippedForUpload(sdkDirectory, problem.reason)}`);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Putting The SDK in in front of the helper's output ties this message to the helper's sentence shape: if notZippedForUpload ever starts with something other than the path, this reads wrong. Option: let the helper take a subject, or keep this one message inline.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Back to its own inline text, as on dev.

reportAuthorizationFailure(failure);
}

public sourceNotZipped(problem: FileProblem, sourceDirectory: DirectoryPath) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: this takes (problem, sourceDirectory), while the sibling messages srcDirNotZipped(sourceDirectory, problem) and sdkNotZipped(sdkDirectory, problem) take the directory first. I see it matches sourceProblem in this class, so take it or leave it.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Leaving it as is: (problem, sourceDirectory) follows sourceProblem in the same class.

Comment thread test/actions/sdk/publish.test.ts Outdated
const problem: FileProblem = { kind: 'zipFailed', reason: 'EACCES: permission denied' };
const zip = sinon.stub(TempContext.prototype, 'zip').callThrough();
zip.onSecondCall().resolves(err(problem));
sinon.stub(TempContext.prototype, 'zip').resolves(err(problem));

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This now stubs every zip() call. It works only because the source zip moved to zipBuild(), and nothing here says so. If the build ever went back through zip(), this test would start failing at the wrong step and still pass for the wrong reason. Consider stubbing SdkContext/TempContext closer to the SDK zip, or add a one-line why-comment.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Moot now: the build zip goes through zip() again, so this test is back to its dev form, which lets the first call through and fails the second one, the SDK zip.

Comment thread src/actions/plugin/generate.ts Outdated
.asyncAndThen(() => new ResultAsync(configContext.stageUpload(tempDirectory, selection)))
.mapErr((failure): PluginGenerationProblem => ({ kind: 'configNotPrepared', failure }))
.andThen((staged) => new ResultAsync(tempContext.zip(staged)))
.andThen((staged) => new ResultAsync(tempContext.zipBuild(staged)))

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This extends the exclusion to /plugin generation, which the issue left open ("I did not verify whether context-plugin generation reads content/"; static/ wasn't checked for plugins either). Has anyone diffed a plugin built with and without static/? I couldn't: my account isn't entitled to plugin generation. Until then I'd soften "plugins are unchanged" in the description.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I generated a plugin from the same small project with 2.0.0, which uploads static/, and with this branch, which doesn't. The two outputs are byte-identical (69 files), and a 23 MB static/ also generates on the branch, so the description keeps "unchanged".

ProjectContext.buildZip and pluginBuildZip now make the upload for the portal, sdk and plugin
commands. They copy src/ without static/ (in any letter case), following links, and report a
failed copy like a failed zip, once the copy has stopped. TempContext and ZipService are back to
what they are on dev, and the shared zip-failure sentence lives in prompts/prompt.ts.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@saeedjamshaid

Copy link
Copy Markdown
Collaborator Author

Thanks for the thorough pass. All of it is addressed in 55fbae3; the inline threads have the details. On the general points:

  1. Plugin scope: a plugin from 2.0.0 and one from this branch are byte-identical.
  2. static/ was still copied: it no longer is. A locked file anywhere in src/ now gets the named message from all three commands, where sdk generate and plugin generate used to crash with the raw EBUSY. The copy now also follows links, so a linked folder or a relative link in src/ works.
  3. Upload part: the description now says the build goes up under a random ID name.
  4. Tests:
    • Case and nesting are pinned on the copy.
    • The rule now lives in ProjectContext, is tested there, and all three commands use it, so I didn't add a separate sdk generate action test.
    • The AdmZip one-liner was already in three test files on dev, so I left it.

A test from #448 places code samples on the source directory's spec/, so the artifacts-service
test keeps that directory beside the zipped build it uploads.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@sonarqubecloud

Copy link
Copy Markdown

@saeedjamshaid
saeedjamshaid merged commit 4b771a8 into dev Oct 27, 2026
10 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants