Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -663,6 +663,13 @@ archive adds bytes of its own for every entry it holds, so a great many
small files that do not compress can weigh more packed than they do on
disk. The message says so, and names the files worth removing.

One limit on what the BUILD produces is also checked here: a project
whose public folder alone holds more than **1,000 files** is refused,
because Astro copies that folder into the built site whole and the server
refuses a site over 1,000 files. That folder's count is a floor on the
site's, so once the folder is known the refusal is certain rather than a
guess; everything else the build emits only the server can count.

These are stated here because they are useful to know before you try. The
client checks them so you get a fast, local, specific answer instead of a
failed upload — but **the client is not the boundary.** Every one of them
Expand Down
27 changes: 21 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -238,15 +238,19 @@ archive adds bytes of its own for every entry it holds, so a great many
small, incompressible files can weigh more once packed than they do on
disk — `curious` names the files worth removing when that happens.

Two more numbers bound what the build produces, and only the server can
check them, because the output does not exist until the build has run:
Two more numbers bound what the build produces, and the server checks
both once the build has run:

- `MaxOutputFiles` — 1,000 files at most.
- `MaxOutputTotalBytes` — 30 MB in total, counting the small badge added
to each HTML page.

A site over either is refused when its build finishes, with a message
giving the size it reached and the limit.
One part of the first is known before anything is sent. Astro copies the
public folder into the site whole, so `curious` counts that folder
locally and refuses a project whose public folder alone holds more than
1,000 files. Everything else the build emits, only the server can count.
A site over either limit is refused with a message giving the size it
reached and the limit.

## Where the login lives

Expand Down Expand Up @@ -488,6 +492,17 @@ It names how many files the project holds and how many one deploy may
carry, then the handful of directories holding the most of them — so you
have somewhere to point an ignore rule rather than a list to read.

### limit-public-files

**pre-flight.** A person is having the project checked before anything leaves the machine.

_The message here is assembled at run time, so there is no fixed sentence to quote — see "A note on composed messages," below._

It names the public folder, how many files it holds and the most a site
can hold, then the handful of directories inside it holding the most
files. The build copies that folder into the site whole, so a folder over
the limit is a site over the limit before anything else is added.

### limit-packed

**pre-flight.** A person is having the project checked before anything leaves the machine.
Expand Down Expand Up @@ -843,7 +858,7 @@ and the message says so rather than blaming your network.

### A note on composed messages

Eighteen (id, stage) pairs build their message from the situation at run
Nineteen (id, stage) pairs build their message from the situation at run
time rather than writing one fixed sentence, and are deliberately not
templated above: a hand-typed guess at their shape would be wording
nothing checks, which is the exact fragility this whole section exists
Expand All @@ -856,7 +871,7 @@ to end. They are:
`upload-refused-unexplained`, `upload-signature-mismatch`,
`upload-stalled`.
- **pre-flight** — `limit-file-size`, `limit-files`, `limit-packed`,
`limit-total`, `path-charset`.
`limit-public-files`, `limit-total`, `path-charset`.
- **this machine** — `project-dir-missing`, `project-dir-unreadable`,
`project-path-not-a-directory`.

Expand Down
16 changes: 16 additions & 0 deletions catalog.json
Original file line number Diff line number Diff line change
Expand Up @@ -391,6 +391,22 @@
"pre-flight"
]
},
{
"action": [
"FreshDeploy"
],
"family": "limit-public-files",
"headline": {
"pre-flight": null
},
"headline_reason": {
"pre-flight": "composed"
},
"id": "limit-public-files",
"stages": [
"pre-flight"
]
},
{
"action": [
"FreshDeploy"
Expand Down
27 changes: 15 additions & 12 deletions internal/check/check.go
Original file line number Diff line number Diff line change
Expand Up @@ -87,9 +87,10 @@ const (
IDCaseCollision = "case-collision"
IDPathCharset = "path-charset"

// The four the local limits own: how many files there are, how big
// the largest is, how big they are together, and how big the archive
// turned out once they were packed.
// The five the local limits own: how many files there are, how many
// of them sit in the public folder, how big the largest is, how big
// they are together, and how big the archive turned out once they were
// packed.
//
// THEY ARE IN THE UNIVERSE RATHER THAN OUTSIDE IT, and that was a
// decision with a real alternative. Leaving them out would work —
Expand All @@ -106,10 +107,11 @@ const (
// with no limit rows cannot be built at all, so a legitimate hard
// stop found before them would be a hard stop the program could not
// render.
IDLimitFiles = "limit-files"
IDLimitFileSize = "limit-file-size"
IDLimitTotal = "limit-total"
IDLimitPacked = "limit-packed"
IDLimitFiles = "limit-files"
IDLimitPublicFiles = "limit-public-files"
IDLimitFileSize = "limit-file-size"
IDLimitTotal = "limit-total"
IDLimitPacked = "limit-packed"
)

// Finding is one pre-flight check's result: which check produced it, how
Expand Down Expand Up @@ -399,9 +401,10 @@ const (
FamilyLockfileWorkspace FailureFamily = "lockfile-workspace"
FamilyLockfileMissing FailureFamily = "lockfile-missing"

FamilyLimitFiles FailureFamily = "limit-files"
FamilyLimitFileSize FailureFamily = "limit-file-size"
FamilyLimitTotal FailureFamily = "limit-total"
FamilyLimitPacked FailureFamily = "limit-packed"
FamilyPathCharset FailureFamily = "path-charset"
FamilyLimitFiles FailureFamily = "limit-files"
FamilyLimitPublicFiles FailureFamily = "limit-public-files"
FamilyLimitFileSize FailureFamily = "limit-file-size"
FamilyLimitTotal FailureFamily = "limit-total"
FamilyLimitPacked FailureFamily = "limit-packed"
FamilyPathCharset FailureFamily = "path-charset"
)
4 changes: 2 additions & 2 deletions internal/check/check_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -221,7 +221,7 @@ func TestDeclaredOrderIsTheCompleteUniverseInReportOrder(t *testing.T) {
want := []string{
IDAstroDep, IDLockfile, IDPagesDir, IDBuildFormat, IDLocalhost,
IDSymlinks, IDCaseCollision, IDPathCharset,
IDLimitFiles, IDLimitFileSize, IDLimitTotal, IDLimitPacked,
IDLimitFiles, IDLimitPublicFiles, IDLimitFileSize, IDLimitTotal, IDLimitPacked,
}
if got := DeclaredOrder(); !reflect.DeepEqual(got, want) {
t.Errorf("DeclaredOrder() = %v, want %v", got, want)
Expand All @@ -243,7 +243,7 @@ func TestDeclaredOrderHandsBackACopy(t *testing.T) {
want := []string{
IDAstroDep, IDLockfile, IDPagesDir, IDBuildFormat, IDLocalhost,
IDSymlinks, IDCaseCollision, IDPathCharset,
IDLimitFiles, IDLimitFileSize, IDLimitTotal, IDLimitPacked,
IDLimitFiles, IDLimitPublicFiles, IDLimitFileSize, IDLimitTotal, IDLimitPacked,
}
if got := DeclaredOrder(); !reflect.DeepEqual(got, want) {
t.Errorf("after a caller overwrote what it was given, DeclaredOrder() = %v, want %v",
Expand Down
8 changes: 5 additions & 3 deletions internal/check/combine_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ func TestCombineMergesProducersIntoOneOrderedResult(t *testing.T) {
limits := Results{
Manifest: Manifest{
{CheckID: IDLimitFiles},
{CheckID: IDLimitPublicFiles},
{CheckID: IDLimitFileSize},
{CheckID: IDLimitTotal},
{CheckID: IDLimitPacked, Outcome: Declined, Kind: ByDesign, Reason: "nothing packed yet"},
Expand Down Expand Up @@ -81,7 +82,7 @@ func TestCombineMergesProducersIntoOneOrderedResult(t *testing.T) {
wantManifest := []string{
IDAstroDep, IDLockfile, IDPagesDir, IDBuildFormat, IDLocalhost,
IDSymlinks, IDCaseCollision, IDPathCharset,
IDLimitFiles, IDLimitFileSize, IDLimitTotal, IDLimitPacked,
IDLimitFiles, IDLimitPublicFiles, IDLimitFileSize, IDLimitTotal, IDLimitPacked,
}
if rows := ids(got.Manifest()); !reflect.DeepEqual(rows, wantManifest) {
t.Errorf("manifest = %v, want the declared order %v", rows, wantManifest)
Expand Down Expand Up @@ -231,6 +232,7 @@ func TestCombineDoesNotDisturbTheProducersItWasGiven(t *testing.T) {
{CheckID: IDCaseCollision},
{CheckID: IDPathCharset},
{CheckID: IDLimitFiles},
{CheckID: IDLimitPublicFiles},
{CheckID: IDLimitFileSize},
{CheckID: IDLimitTotal},
{CheckID: IDLimitPacked},
Expand All @@ -247,7 +249,7 @@ func TestCombineDoesNotDisturbTheProducersItWasGiven(t *testing.T) {
if rows := ids(producer.Manifest); !reflect.DeepEqual(rows, []string{
IDLocalhost, IDAstroDep, IDLockfile, IDPagesDir, IDBuildFormat,
IDSymlinks, IDCaseCollision, IDPathCharset,
IDLimitFiles, IDLimitFileSize, IDLimitTotal, IDLimitPacked,
IDLimitFiles, IDLimitPublicFiles, IDLimitFileSize, IDLimitTotal, IDLimitPacked,
}) {
t.Errorf("the caller's manifest was reordered: %v", rows)
}
Expand All @@ -273,7 +275,7 @@ func TestCoverageGapsReportsBothDirections(t *testing.T) {
wantMissing := []string{
IDLockfile, IDPagesDir, IDBuildFormat,
IDSymlinks, IDCaseCollision, IDPathCharset,
IDLimitFiles, IDLimitFileSize, IDLimitTotal, IDLimitPacked,
IDLimitFiles, IDLimitPublicFiles, IDLimitFileSize, IDLimitTotal, IDLimitPacked,
}
if !reflect.DeepEqual(missing, wantMissing) {
t.Errorf("missing = %v, want %v in the declared order", missing, wantMissing)
Expand Down
2 changes: 1 addition & 1 deletion internal/check/coverage_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,7 @@ func TestCombinedCoverageEqualsTheDeclaredUniverse(t *testing.T) {
t.Fatalf("walking an empty directory: %v", err)
}

combined, err := check.Combine(engine, tree.Results, pack.Limits(tree.Files))
combined, err := check.Combine(engine, tree.Results, pack.Limits(tree.Files, pack.DefaultNameScope.Public))
if err != nil {
t.Fatalf("combining the producers: %v", err)
}
Expand Down
8 changes: 5 additions & 3 deletions internal/check/order.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,12 +27,13 @@ import "sort"
// among the three still appears above every warning — what this decides
// is the sequence WITHIN a severity, and the order of the manifest.
//
// THE FOUR LIMITS COME LAST, and the principle is the same one again.
// THE FIVE LIMITS COME LAST, and the principle is the same one again.
// Everything above answers whether this project can BUILD; these answer
// whether it can be SENT, which is a question about something already
// established to be a project. Among themselves they run smallest
// question first — how many files there are, then how big one of them
// is, then how big they are together — and the packed size last of all,
// question first — how many files there are, then how many of them the
// public folder holds, then how big one of them is, then how big they are
// together — and the packed size last of all,
// because it is the only one whose subject does not exist until every
// other has passed.
var declaredOrder = []string{
Expand All @@ -45,6 +46,7 @@ var declaredOrder = []string{
IDCaseCollision,
IDPathCharset,
IDLimitFiles,
IDLimitPublicFiles,
IDLimitFileSize,
IDLimitTotal,
IDLimitPacked,
Expand Down
1 change: 1 addition & 0 deletions internal/check/report_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -236,6 +236,7 @@ func TestCombineIsIndependentOfArgumentOrder(t *testing.T) {
{CheckID: IDCaseCollision},
{CheckID: IDPathCharset},
{CheckID: IDLimitFiles},
{CheckID: IDLimitPublicFiles},
{CheckID: IDLimitFileSize},
{CheckID: IDLimitTotal},
{CheckID: IDLimitPacked},
Expand Down
2 changes: 1 addition & 1 deletion internal/flow/deploy.go
Original file line number Diff line number Diff line change
Expand Up @@ -459,7 +459,7 @@ func Deploy(ctx context.Context, deps DeployDeps) (*Handoff, error) {
//
// The same gate is what makes the check list below checkable from
// outside: forget one, and the report cannot be built at all.
limits := pack.Limits(tree.Files)
limits := pack.Limits(tree.Files, folders.Public)
report, err := check.Combine(
preflight.Run(deployChecks(tree.Files), preflight.OSFileSystem{}, root),
tree.Results,
Expand Down
101 changes: 100 additions & 1 deletion internal/flow/deploy_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import (
"github.com/curiouspub/cli/internal/config"
"github.com/curiouspub/cli/internal/pack"
"github.com/curiouspub/cli/internal/ui"
"github.com/curiouspub/cli/internal/units"
"github.com/curiouspub/cli/pkg/wire"
)

Expand Down Expand Up @@ -2024,7 +2025,7 @@ func TestTheDeployChecksNamesWhereTheSiteKeepsThem(t *testing.T) {
_, err := run.run()
events := run.journal.all()
said := strings.Join(events, "\n")
if !strings.Contains(said, "checked under public/ instead") {
if !strings.Contains(said, "checked and files counted under public/ instead") {
t.Errorf("the run never said where it checked:\n %s", strings.Join(events, "\n "))
}
if indexOfEvent(events, "asked: Continue anyway?") < 0 {
Expand Down Expand Up @@ -2198,6 +2199,104 @@ func TestWhatEachEndingCosts(t *testing.T) {
// What a refused run leaves behind
// -------------------------------------------------------------------

// publicFolderFiles is n small files under dir, nested two deep, with one
// hidden file among them, shaped as astroProject's extra argument.
func publicFolderFiles(dir string, n int) map[string][]byte {
out := map[string][]byte{dir + "/.well-known": []byte("x")}
for i := 1; i < n; i++ {
out[fmt.Sprintf("%s/d%d/e%d/f%04d.txt", dir, i%2, i%3, i)] = []byte("x")
}
return out
}

// whatThePublicStopSaid is everything a person would have read: the
// refusal as rendered, with its failure id line, and every journal event.
func whatThePublicStopSaid(t *testing.T, run *deployRun, err error) string {
t.Helper()
text, _ := renderedBytes(t, err)
return text + "\n" + rendered(err) + "\n" + strings.Join(run.journal.all(), "\n")
}

// TestAPublicFolderOverTheOutputCapIsRefusedBeforeAnythingIsSent proves
// the flow hands the count the folder it resolved: the default folder, a
// configured one, and the fallback when the config cannot be read. Astro
// copies that folder into the site whole, and the server refuses a site
// over the file cap, so the answer is available locally and free.
//
// REQUIRED MUTATION, run 2026-10-04: have Deploy pass a fixed "public" to
// the limits. The configured-folder half reds; the others stay green.
// Also run: make the finding a warning. The default, configured and
// fallback halves red, and the control stays green. Also run: record the
// public-folder row as declined. The default and fallback halves red, and
// so does the control.
func TestAPublicFolderOverTheOutputCapIsRefusedBeforeAnythingIsSent(t *testing.T) {
refused := func(t *testing.T, run *deployRun) string {
t.Helper()
handoff, err := run.run()
if err == nil {
handoff.Release()
t.Fatal("a public folder over the cap deployed")
}
if handoff != nil {
t.Error("a refused run handed back a request")
}
if sent := run.script.sent(); sent != 0 {
t.Errorf("the refused project sent %d requests (%v), want none", sent, run.script.paths)
}
if left := run.leftBehind(); len(left) != 0 {
t.Errorf("the run left %v behind", left)
}
return whatThePublicStopSaid(t, run, err)
}

t.Run("the default folder", func(t *testing.T) {
run := newDeployRun(t, writeProject(t, astroProject(publicFolderFiles("public", 1_001))))
said := refused(t, run)
for _, want := range []string{"limit-public-files", units.Count(1_001), units.Count(wire.MaxOutputFiles)} {
if !strings.Contains(said, want) {
t.Errorf("the output does not contain %q:\n%s", want, said)
}
}
})

t.Run("a configured folder", func(t *testing.T) {
files := publicFolderFiles("static", 1_001)
files["astro.config.mjs"] = []byte("export default { publicDir: './static' };\n")
run := newDeployRun(t, writeProject(t, astroProject(files)))
said := refused(t, run)
if !strings.Contains(said, "limit-public-files") {
t.Errorf("the output does not name the public-folder stop:\n%s", said)
}
})

t.Run("a folder the config could not settle falls back, and says so", func(t *testing.T) {
files := publicFolderFiles("public", 1_001)
files["astro.config.mjs"] = []byte("const dir = './static';\nexport default { publicDir: dir };\n")
run := newDeployRun(t, writeProject(t, astroProject(files)))
said := refused(t, run)
for _, want := range []string{"limit-public-files", "checked and files counted under public/ instead"} {
if !strings.Contains(said, want) {
t.Errorf("the output does not contain %q:\n%s", want, said)
}
}
})

t.Run("control: a folder at the cap deploys and sends", func(t *testing.T) {
run := newDeployRun(t, writeProject(t, astroProject(publicFolderFiles("public", 1_000)))).scriptedLogin()
run.prompt.confirms = []answer{no()}

handoff, err := run.run()
if err != nil {
t.Fatalf("Deploy: %v\n%s", err, rendered(err))
}
defer handoff.Release()
if run.script.sent() == 0 {
t.Error("the control sent nothing either, so the zeros above say nothing " +
"about the stop and everything about the instrument")
}
})
}

// TestARefusedRunPacksNothingAndLeavesNothingBehind covers every way a
// run can stop before the pack, and its control is the run that does
// pack.
Expand Down
9 changes: 6 additions & 3 deletions internal/mcp/deploy.go
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,10 @@ func deploySiteDescription() string {
"network call at all. The client refuses a project with more than %d files, "+
"any single file over %d bytes, more than %d bytes of source in total, or an "+
"archive over %d bytes once packed — and the server checks every one of them "+
"again, so these are a fast local answer rather than the boundary. The build "+
"again, so these are a fast local answer rather than the boundary. The client "+
"also refuses, before any network call, a project whose public folder holds "+
"more than %d files: the build copies that folder into the site whole, so the "+
"server would refuse the site. The build "+
"must also produce no more than %d files and no more than %d bytes.\n\n"+
"WARNINGS DO NOT STOP A DEPLOY HERE. Anything the pre-flight checks found "+
"comes back in findings, with a severity on each, and the deploy goes ahead — "+
Expand All @@ -115,8 +118,8 @@ func deploySiteDescription() string {
"The address starts answering a little after the deploy is published, so opening "+
"it immediately may show a placeholder page.",
wire.MaxSourceFiles, wire.MaxSourceFileBytes, wire.MaxSourceTotalBytes,
wire.MaxPackedBytes, wire.MaxOutputFiles, wire.MaxOutputTotalBytes,
toolLoginStart)
wire.MaxPackedBytes, wire.MaxOutputFiles, wire.MaxOutputFiles,
wire.MaxOutputTotalBytes, toolLoginStart)
}

func deploySiteTool() Tool {
Expand Down
Loading
Loading