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
18 changes: 18 additions & 0 deletions .changeset/blank-template-console-disclosure.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
---
"create-objectstack": patch
---

Tell a newcomer that the `blank` starter ships no app, so an empty Console
reads as the intended starting point rather than a broken install (#10317).

Measured on a real scaffold-and-boot (`create-objectstack my-app -t blank`,
published 17.1.0 packages, `objectstack dev --ui`): `GET /api/v1/meta/app`
returns the two platform apps (Setup, Account) and nothing of the project's
own, while `GET /api/v1/data/my_app_note` serves the scaffolded object the
whole time. The template ships `src/objects/` only — deliberately, as every
scaffolder template in this repo does — but nothing the newcomer could reach
said so, and `pnpm dev` advertises the Console URL on every boot.

Documentation only: a new "The Console" section in the generated `README.md`
naming the Console path, the consequence, and `src/apps/*.app.ts` as the
remedy. No change to what the scaffolder writes into `src/`.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
// Copyright (c) 2026 ObjectStack contributors. Apache-2.0 license.
//
// Keeps the blank template's TREE and its README telling the same story about
// whether a scaffolded project renders in the Console.
//
// ## What was measured
//
// Scaffolded with the real CLI (`create-objectstack my-app -t blank`), installed
// against the published 17.1.0 packages, and booted with `objectstack dev --ui`:
//
// GET /api/v1/meta/app -> 200, items: [setup, account] (2, both platform)
// GET /api/v1/data/my_app_note-> 200 {"records":[],"total":0}
//
// The scaffolder's own object is live over REST for the whole session and is in
// no app's navigation, because `src/` ships `objects/` and nothing else. Adding
// a single `*.app.ts` to the scaffolded project and rebooting takes the same
// endpoint to three items, the third being the project's own app — so the empty
// `src/apps/` is the entire cause, and an app is the entire remedy.
//
// That emptiness is DELIBERATE, and this test does not challenge it. Every
// scaffolder template in this repo ships objects only: `create-objectstack`'s
// `blank` ("Minimal starter — one object, REST API, ready to extend") and all
// three `os init` templates — including the one literally described as "Full
// application with objects". No `*.app.ts` has ever existed under
// `src/templates/**` in this repo's history. Apps are what the agent authors in
// step 2 of the README loop, not what the scaffolder pre-writes into every
// project forever.
//
// What was missing is that nothing the newcomer can reach said so, while the
// dev-server banner advertises the Console URL on every boot. Hence the pin
// below: as long as the template ships no app, its README must disclose the
// Console consequence and name the remedy.
//
// The assertion is deliberately BIDIRECTIONAL. A one-way "README must say X"
// check rots the moment someone adds an app to the template — the README would
// keep claiming an empty starting point and the test would stay green on a
// sentence that had become false. So the tree decides which claim is required.

import { describe, it, expect } from 'vitest';
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';

const HERE = path.dirname(fileURLToPath(import.meta.url));
const blankRoot = path.resolve(HERE, 'templates', 'blank');
const readme = fs.readFileSync(path.join(blankRoot, 'README.md'), 'utf8');

/** Every file under `dir`, recursively, as paths relative to `dir`. */
function listFiles(dir: string): string[] {
const out: string[] = [];
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
const rel = entry.name;
if (entry.isDirectory()) {
out.push(...listFiles(path.join(dir, rel)).map((f) => path.join(rel, f)));
} else if (entry.isFile()) {
out.push(rel);
}
}
return out;
}

const srcFiles = listFiles(path.join(blankRoot, 'src'));
const appFiles = srcFiles.filter((f) => f.endsWith('.app.ts'));

describe('blank template — tree and README agree about the Console', () => {
// Vacuity guard. Every assertion below is conditioned on the template's own
// tree, so a test that read the wrong directory would silently assert
// nothing. This proves the tree really was read.
it('reads the real template tree', () => {
expect(srcFiles).toContain(path.join('objects', 'note.object.ts'));
});

it('ships objects only — no app, no views', () => {
// Not a style preference: this is the measured starting point the README
// section is written against. Changing it is a product decision, and the
// failure message says so rather than inviting a quiet re-baseline.
expect(
appFiles,
'The blank template now ships an app. That is a product change, not a ' +
'test failure to silence: update the README section "The Console" to ' +
'describe what actually renders, then update this expectation.',
).toEqual([]);
});

it('discloses that no app means no Console navigation', () => {
if (appFiles.length > 0) {
// The template ships an app — the "no app" claim must be gone.
expect(readme).not.toMatch(/ships no app|no app and no views/i);
return;
}

// Points the reader at the Console they are about to be sent to by the
// `pnpm dev` banner.
expect(readme, 'README must name the Console path').toMatch(/\/_console\//);

// States the consequence, so an empty Console does not read as "it broke".
expect(
readme,
'README must say this starter ships no app',
).toMatch(/ships no app|no app and no views/i);

// Names the remedy concretely enough to act on.
expect(
readme,
'README must name *.app.ts / src/apps/ as the remedy',
).toMatch(/src\/apps\/|\*\.app\.ts/);

// Ties the two together — the rule itself, not just the two nouns.
expect(
readme,
'README must state the app -> navigation rule',
).toMatch(/Console navigation only when an app lists it/i);
});

it('does not hard-code the rewritten object name', () => {
// `rewrite-identity.ts` rewrites `.ts` files only, so a README naming the
// template's own `blank_note` would ship literally into `my-app`, where the
// object is `my_app_note`. The README must stay generic.
expect(readme).not.toMatch(/\bblank_note\b/);
});
});
17 changes: 17 additions & 0 deletions packages/create-objectstack/src/templates/blank/README.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -21,6 +21,23 @@ curl -c cookies.txt -X POST http://localhost:3000/api/v1/auth/sign-in/email \
curl -b cookies.txt "http://localhost:3000/api/v1/data/<your_object>"
```

## The Console — this starter ships no app

`pnpm dev` also serves the admin Console at `http://localhost:3000/_console/`,
and prints the link on boot. Open it and you will see the platform's own apps
(Setup, Account) and **not** the object in `src/objects/` — this starter ships
objects only, with no app and no views.

That is the intended starting point, not a broken install. The object is live
the whole time — the `curl` above returns it, and an MCP client can read and
write it. What it has no route into is the Console's navigation.

**An object appears in Console navigation only when an app lists it.** Add an
`*.app.ts` under `src/apps/` (plus the views it points at), and the Console
renders it after the next `pnpm dev` rebuild. The `objectstack-ui` skill covers
the shape; describing the app you want to your coding agent is the intended
path.

## Your app is an MCP server

Every ObjectStack app is itself a
Expand Down
Loading