zz-meridian 0.6.1 → 0.8.0
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.
- package/README.md +16 -3
- package/dist/adopt.js +6 -2
- package/package.json +1 -1
- package/payload/CHANGELOG.md +23 -0
- package/payload/README.md +38 -19
- package/payload/app/sign-in/README.md +10 -0
- package/payload/app/sign-in/actions.ts +19 -0
- package/payload/app/sign-in/gated-panel.tsx +9 -0
- package/payload/app/sign-in/page.tsx +4 -2
- package/payload/app/sign-in/panel.tsx +36 -5
- package/payload/decisions/0010-the-adopter-contract.md +6 -4
- package/payload/docs/distribution.md +20 -10
- package/payload/gitignore +10 -0
- package/payload/next.config.ts +3 -0
- package/payload/package.json +1 -1
- package/payload/proxy.ts +37 -0
- package/payload/scripts/check.ts +7 -5
- package/payload/scripts/lib/bin.ts +4 -0
- package/payload/skills/zz-meridian/SKILL.md +66 -29
- package/payload/skills/zz-meridian/references/update.md +4 -0
- package/payload/src/lib/demo-gate.ts +39 -0
- package/payload/tests/demo-gate.test.ts +64 -0
- package/payload/tests/telemetry.test.ts +15 -0
package/README.md
CHANGED
|
@@ -6,10 +6,15 @@ dashboard depends on it afterwards.
|
|
|
6
6
|
|
|
7
7
|
## The one sentence
|
|
8
8
|
|
|
9
|
-
Give your coding agent (Codex, Claude Code, or any agent that can run a shell) this,
|
|
9
|
+
Give your coding agent (Codex, Claude Code, or any agent that can run a shell) this, with what you want in your own
|
|
10
|
+
words at the end:
|
|
10
11
|
|
|
11
|
-
> Run `npx zz-meridian@latest
|
|
12
|
-
|
|
12
|
+
> Run `npx zz-meridian@latest skill --global`, then follow the zz-meridian skill it installs to: [what you want, in your own words].
|
|
13
|
+
|
|
14
|
+
The skill picks the command from what you said and what is in the folder, and names it before running anything:
|
|
15
|
+
`adopt` to change this Next.js App Router project in place ("make this admin panel look professional"), `create` for a
|
|
16
|
+
new dashboard in a new folder ("build an ops dashboard", "a new one based on this folder, in another folder"), and
|
|
17
|
+
`update` or `brand` for a project that already has Meridian.
|
|
13
18
|
|
|
14
19
|
## Commands
|
|
15
20
|
|
|
@@ -72,6 +77,14 @@ sends nothing anywhere; the only network access is your package manager's instal
|
|
|
72
77
|
`--no-install`. Every release is built and published by GitHub Actions with npm provenance, so the registry shows the
|
|
73
78
|
commit and workflow each version came from.
|
|
74
79
|
|
|
80
|
+
## Privacy and feedback
|
|
81
|
+
|
|
82
|
+
The package collects nothing: no telemetry, no analytics, no account, and the template turns off Next.js's anonymous
|
|
83
|
+
telemetry. It talks to npm only, when `npx` fetches it and when `update` reads releases. Feedback reaches us only as a
|
|
84
|
+
[GitHub issue](https://github.com/zhixuan312/zz-meridian/issues/new/choose), a Bug or a Feature request; issues are
|
|
85
|
+
public, so leave out anything that identifies you, your organisation or your product, and describe the problem with
|
|
86
|
+
Meridian's own template or sample data.
|
|
87
|
+
|
|
75
88
|
## License
|
|
76
89
|
|
|
77
90
|
MIT
|
package/dist/adopt.js
CHANGED
|
@@ -258,8 +258,12 @@ Rename or move them, then run adopt again.`);
|
|
|
258
258
|
writes.set(`${dir}/icon.ts`, exactImports(`${dir}/icon.ts`, readPayload('app/icon.ts'), known));
|
|
259
259
|
const ignore = path.join(root, '.gitignore');
|
|
260
260
|
const ignored = fs.existsSync(ignore) ? fs.readFileSync(ignore, 'utf8') : '';
|
|
261
|
-
|
|
262
|
-
|
|
261
|
+
// verify's report and screenshots, and an update in progress: Meridian's own, never the team's to commit. The manifest,
|
|
262
|
+
// keep.json and history/ are committed, since the next update reads them.
|
|
263
|
+
const bare = (l) => l.trim().replace(/^\//, '').replace(/\/$/, '');
|
|
264
|
+
const unignored = ['/out/', '/.meridian/update/', '/.meridian/update.lock'].filter((l) => !ignored.split('\n').some((x) => bare(x) === bare(l)));
|
|
265
|
+
if (unignored.length)
|
|
266
|
+
writes.set('.gitignore', `${ignored.trimEnd()}\n\n# zz-meridian: verify's report and an update in progress\n${unignored.join('\n')}\n`);
|
|
263
267
|
// ── Write: every target checked first, so a bad one stops it before anything changes ─────────────────
|
|
264
268
|
try {
|
|
265
269
|
for (const rel of writes.keys())
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "zz-meridian",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.8.0",
|
|
4
4
|
"description": "Bring ZZ Meridian, a dashboard design system, into a Next.js project, or start a new dashboard on it. Copies the files in; nothing depends on this package at runtime.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
package/payload/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,29 @@
|
|
|
2
2
|
|
|
3
3
|
Every release of ZZ Meridian, newest first. Versions follow semver: a removed or renamed token, prop or card is major; a new card, token or variant is minor; a corrected value is a patch. Each entry says what breaks and what to do instead.
|
|
4
4
|
|
|
5
|
+
## [0.8.0] · 2026-10-06
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
|
|
9
|
+
- **A demo password in front of the whole product.** With `DEMO_PASSWORD` set at run time, `proxy.ts` sends every route to the sign-in page (an API answers 401), whose panel becomes "Open the demo": one password field, a 30-day signed session (`src/lib/demo-gate.ts`, keyed by `DEMO_SECRET` when set), and opening the page again signs out. Without it nothing changes: the panel is the product's sign-in and nothing is gated. The sign-in panel now streams in behind a Suspense boundary, so the rest of the page still prerenders. Meridian's own demo deploys to CapRover from this repository (`Dockerfile`, `captain-definition`, neither in the package).
|
|
10
|
+
|
|
11
|
+
### Changed
|
|
12
|
+
|
|
13
|
+
- **`update` is tested from the last three releases.** The release updates a project of the release before, adopted and created, through finalize; the weekly run the last three. Every published origin used to run at each release, which took 11 of its 18 minutes and grew with every release. From an older project, update in steps (`references/update.md`).
|
|
14
|
+
|
|
15
|
+
### Fixed
|
|
16
|
+
|
|
17
|
+
- **A project leaves an update in progress and each person's agent settings out of git.** Its `.gitignore` (the template's, for a created project; three lines `adopt` adds, for an adopted one) ignores `.meridian/update/` and `.meridian/update.lock`, the staged copies, backups and lock of an unfinished update, which a commit mid-session used to take in; a created project also ignores `.claude/settings.local.json` and `.claude/*.lock`. The manifest, `keep.json`, `.meridian/history/` and both copies of the skill stay committed: the next update and the team's agents read them. In a project made before 0.8.0, add those lines to `.gitignore`.
|
|
18
|
+
- **The one sentence shows its blank.** It ended in `<what you want>`, which GitHub and npm read as an HTML tag and dropped, so it read "installs to: .". It ends in `[what you want, in your own words]`.
|
|
19
|
+
|
|
20
|
+
## [0.7.0] · 2026-10-06
|
|
21
|
+
|
|
22
|
+
### Changed
|
|
23
|
+
|
|
24
|
+
- **One sentence for every route, and the skill chooses the command.** The README's sentence is `Run npx zz-meridian@latest skill --global, then follow the zz-meridian skill it installs to: <what you want>`. The skill opens with "Choose the route": a table from what the person said and what is in the folder to `adopt` (this Next.js App Router project, in place), `create` (a new folder, reading any folder named as the source and never writing it) or `update` and `brand`, and it says the route before the first command. The old sentence named `adopt`, which is wrong for a new dashboard.
|
|
25
|
+
- **Next.js telemetry is off.** The template's `next.config.ts` sets `NEXT_TELEMETRY_DISABLED` for `next dev` and `next build`, and Meridian's scripts set it for every `next` they run, an adopted project's included. Delete the line in `next.config.ts` to send it.
|
|
26
|
+
- **Feedback is an offer, and it identifies no one.** The skill's last step drafts a Bug or a Feature request issue only when the run found something about Meridian, removes every name, address, URL, record, schema and path of the person's own, shows the draft, and files nothing without a yes. The repository has Bug and Feature request issue forms that say the same, and no blank issues. The README and the npm page say what reaches the network (npm, the font download at build, what the team configures) and that an issue is the only way anything reaches Meridian.
|
|
27
|
+
|
|
5
28
|
## [0.6.1] · 2026-10-06
|
|
6
29
|
|
|
7
30
|
### Fixed
|
package/payload/README.md
CHANGED
|
@@ -74,31 +74,50 @@ Dark is the default, written on `:root`; the light theme follows the operating s
|
|
|
74
74
|
| `scripts/` | Generators, gates, `brand.ts` and `verify.ts` (see `CONTRIBUTING.md`) |
|
|
75
75
|
| `skills/zz-meridian/` | The agent skill (Claude Code, Codex) that builds dashboards on this template or brings it into yours |
|
|
76
76
|
| `cli/` | The `zz-meridian` npm package: `create`, `adopt`, `update`, `brand` and `skill`, its build, its smoke test and the fixture app (`docs/distribution.md`) |
|
|
77
|
-
| `.github/workflows/release.yml` | The release, each check once: a timed default verify and the consumer path from the tarball (
|
|
77
|
+
| `.github/workflows/release.yml` | The release, each check once: a timed default verify and the consumer path from the tarball (the release before updated; the last three weekly), then npm with provenance, the registry's bytes and provenance checked, then the tag (`.claude/commands/release.md`) |
|
|
78
78
|
| `.github/workflows/weekly.yml` | Weekly, never at release: `verify --full --perf` (perf a report) and the consumer smoke's recovery and failure cases; nothing waits on it |
|
|
79
|
+
| `Dockerfile`, `captain-definition` | The demo deployment: the template and the Atlas behind `DEMO_PASSWORD` (`proxy.ts`, `app/sign-in/README.md`), deployed to CapRover from this repository; never in the package |
|
|
79
80
|
|
|
80
|
-
##
|
|
81
|
+
## Get a dashboard on Meridian, in one sentence
|
|
81
82
|
|
|
82
|
-
Give your coding agent (Codex, Claude Code, or any agent that can run a shell) this,
|
|
83
|
+
Give your coding agent (Codex, Claude Code, or any agent that can run a shell) this, with what you want in your own
|
|
84
|
+
words at the end:
|
|
83
85
|
|
|
84
|
-
> Run `npx zz-meridian@latest
|
|
86
|
+
> Run `npx zz-meridian@latest skill --global`, then follow the zz-meridian skill it installs to: [what you want, in your own words].
|
|
85
87
|
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
for byte) and an empty `docs/brief.md` for your product's own context, and installs the skill. The agent does the judgement: rebuilding each page on Meridian, and running
|
|
89
|
-
`pnpm verify` until the project meets the standard (and `pnpm verify --full` after a change to shared components, the shell or
|
|
90
|
-
data). It needs Node 22.18+, and Google Chrome for the browser checks. If your pages call a live API, say so in the sentence:
|
|
91
|
-
`verify --full` presses every control, Delete included, so the agent builds a fake API first. For a new dashboard: `npx zz-meridian@latest create <dir>`. To move a project to a newer
|
|
92
|
-
Meridian, run `npx zz-meridian@latest update --dry-run`, then follow the skill's `references/update.md`. The package
|
|
93
|
-
(`cli/`, decision 0009) copies files and nothing depends on it afterwards.
|
|
88
|
+
You do not choose between the package's commands; the skill does, from what you said and what is in the folder, and
|
|
89
|
+
names the route before it runs anything:
|
|
94
90
|
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
91
|
+
| You say | The skill |
|
|
92
|
+
|---|---|
|
|
93
|
+
| "Make this admin panel look professional", "change this product into our new dashboard" | `adopt` here, keeping your data layer and routes, when it is Next.js with the App Router; otherwise `create` next to it and port your pages |
|
|
94
|
+
| "Build an ops dashboard for our shipments", "turn this schema into a dashboard" | `create` in a new folder |
|
|
95
|
+
| "Based on this folder, make a new dashboard in another folder" | `create` in that folder, reading this one and leaving it untouched |
|
|
96
|
+
| "Update Meridian", "change our brand colour" | `update` or `brand`, in a project that already has Meridian |
|
|
97
|
+
|
|
98
|
+
`adopt` copies the components, tokens and gates in, merges the dependencies, brands it, adds Meridian's managed block to
|
|
99
|
+
your `AGENTS.md` (your own text there is kept byte for byte) and an empty `docs/brief.md` for your product's own
|
|
100
|
+
context, and installs the skill into the project. `create` writes a new, branded project with the same skill. The
|
|
101
|
+
agent then does the judgement: building each page on Meridian, and running `pnpm verify` until the project meets the
|
|
102
|
+
standard (`pnpm verify --full` after a change to shared components, the shell or data). It needs Node 22.18+, and
|
|
103
|
+
Google Chrome for the browser checks. If your pages call a live API, say so in the sentence: `verify --full` presses
|
|
104
|
+
every control, Delete included, so the agent builds a fake API first. The package (`cli/`, decision 0009) copies files
|
|
105
|
+
and nothing depends on it afterwards; its commands are in `cli/README.md`.
|
|
106
|
+
|
|
107
|
+
## Privacy and feedback
|
|
108
|
+
|
|
109
|
+
Meridian takes nothing from you. The package, the template and the skill have no telemetry, no analytics and no
|
|
110
|
+
account, and the template turns off Next.js's own anonymous telemetry (`next.config.ts`; Meridian's scripts turn it off
|
|
111
|
+
for every `next` they run). What reaches the network is what you would expect: npm, when you run `npx` and when `update`
|
|
112
|
+
reads releases from it; Google Fonts, which `next/font` downloads the typefaces from at build time; and whatever you
|
|
113
|
+
configure yourself, such as the assistant's model provider.
|
|
114
|
+
|
|
115
|
+
The one way to tell us anything is a [GitHub issue](https://github.com/zhixuan312/zz-meridian/issues/new/choose): a
|
|
116
|
+
**Bug** (something in Meridian did the wrong thing) or a **Feature request** (something it should do, or do more
|
|
117
|
+
easily). Issues are public, so leave out anything that identifies you, your organisation or your product: names,
|
|
118
|
+
email addresses, URLs, your data and schemas, screenshots of your pages. Describe the problem with Meridian's own
|
|
119
|
+
template or sample data instead. At the end of a build the skill may offer to draft one with all of that removed; it
|
|
120
|
+
files nothing unless you say yes.
|
|
102
121
|
|
|
103
122
|
## Commands
|
|
104
123
|
|
|
@@ -25,6 +25,16 @@ Two columns from 1024px; one below, the panel under the sentence.
|
|
|
25
25
|
| Invalid email | The field's error: "Enter your work email, like maya@zz-meridian.example." |
|
|
26
26
|
| Sending | The primary button is busy |
|
|
27
27
|
| Sent | The panel says "Check your inbox", names the address, and offers "Use another email" |
|
|
28
|
+
| Demo | With `DEMO_PASSWORD` set at run time, the panel is "Open the demo": one password field (Field, Input `lg`) and "Open the demo" (primary, block, `lg`); a wrong password reads "That is not the demo password." in the field after 600 ms. A match sets a 30-day session and opens the console |
|
|
29
|
+
| Loading | While the panel streams in, its surface holds its place, empty |
|
|
30
|
+
|
|
31
|
+
## The demo gate
|
|
32
|
+
|
|
33
|
+
`proxy.ts` and `src/lib/demo-gate.ts` put a password in front of the whole product when `DEMO_PASSWORD` is set: every
|
|
34
|
+
route but this one redirects here (303), and an API answers 401. Opening this page signs the demo out, which is the
|
|
35
|
+
rail's Sign out. `DEMO_SECRET`, when set, keys the session instead of the password. Without `DEMO_PASSWORD` nothing is
|
|
36
|
+
gated and the panel is the product's sign-in. The panel is the only part of the page that reads the request; the rest
|
|
37
|
+
prerenders (`gated-panel.tsx`, behind a Suspense boundary).
|
|
28
38
|
|
|
29
39
|
## Data
|
|
30
40
|
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
'use server';
|
|
2
|
+
|
|
3
|
+
import { cookies, headers } from 'next/headers';
|
|
4
|
+
import { redirect } from 'next/navigation';
|
|
5
|
+
import { COOKIE, MAX_AGE, samePassword, token } from '@/lib/demo-gate';
|
|
6
|
+
|
|
7
|
+
export type DemoSignInState = { error: boolean };
|
|
8
|
+
|
|
9
|
+
/** Checks the demo password: a match sets the session and opens the console, a miss comes back after 600 ms. */
|
|
10
|
+
export async function demoSignIn(_: DemoSignInState, form: FormData): Promise<DemoSignInState> {
|
|
11
|
+
const given = String(form.get('password') ?? '').slice(0, 512);
|
|
12
|
+
if (!samePassword(given)) {
|
|
13
|
+
await new Promise((r) => setTimeout(r, 600));
|
|
14
|
+
return { error: true };
|
|
15
|
+
}
|
|
16
|
+
const secure = (await headers()).get('x-forwarded-proto') === 'https';
|
|
17
|
+
(await cookies()).set(COOKIE, token(), { httpOnly: true, path: '/', sameSite: 'lax', maxAge: MAX_AGE, secure });
|
|
18
|
+
redirect('/');
|
|
19
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { connection } from 'next/server';
|
|
2
|
+
import { PasswordPanel, SignInPanel } from './panel';
|
|
3
|
+
import { gated } from '@/lib/demo-gate';
|
|
4
|
+
|
|
5
|
+
/** The demo's password when `DEMO_PASSWORD` is set at run time, the product's sign-in otherwise; the page around it prerenders. */
|
|
6
|
+
export async function GatedPanel() {
|
|
7
|
+
await connection();
|
|
8
|
+
return gated() ? <PasswordPanel /> : <SignInPanel />;
|
|
9
|
+
}
|
|
@@ -1,4 +1,6 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { Suspense } from 'react';
|
|
2
|
+
import { GatedPanel } from './gated-panel';
|
|
3
|
+
import { PanelFrame } from './panel';
|
|
2
4
|
import { SampleFooter } from '@/views/sample-footer';
|
|
3
5
|
import { Standalone } from '@/views/standalone';
|
|
4
6
|
import { Meridian } from '@/components/charts/meridian';
|
|
@@ -18,7 +20,7 @@ export default function SignInPage() {
|
|
|
18
20
|
kicker={`${app.name} · Console`}
|
|
19
21
|
sentence="Know your API before your customers do."
|
|
20
22
|
lead="Traffic, latency, spend and health for every endpoint, on your desk, on your phone, and inside the assistant you already use."
|
|
21
|
-
aside={<
|
|
23
|
+
aside={<Suspense fallback={<PanelFrame />}><GatedPanel /></Suspense>}
|
|
22
24
|
>
|
|
23
25
|
{/* The proof is the signature itself: point at a day and the line reads it, as every chart in the console does. */}
|
|
24
26
|
<figure className="mt-12 max-w-xl rounded-xl border border-line bg-surface/60 p-5 backdrop-blur-md max-lg:hidden">
|
|
@@ -1,11 +1,43 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
|
|
3
|
-
import { useState, type FormEvent } from 'react';
|
|
4
|
-
import { ArrowRight, KeyRound, Mail } from 'lucide-react';
|
|
3
|
+
import { useActionState, useState, type FormEvent, type ReactNode } from 'react';
|
|
4
|
+
import { ArrowRight, KeyRound, LockKeyhole, Mail } from 'lucide-react';
|
|
5
5
|
import { Button } from '@/components/ui/button';
|
|
6
6
|
import { Field } from '@/components/ui/field';
|
|
7
7
|
import { Input } from '@/components/ui/input';
|
|
8
8
|
import { app, domain } from '@/app.config';
|
|
9
|
+
import { demoSignIn } from './actions';
|
|
10
|
+
|
|
11
|
+
/** The panel's surface, shared by both panels and the placeholder that holds their place while one streams in. */
|
|
12
|
+
export function PanelFrame({ children }: { children?: ReactNode }) {
|
|
13
|
+
return (
|
|
14
|
+
<section aria-labelledby={children ? 'sign-in' : undefined} aria-hidden={children ? undefined : true} className="relative overflow-hidden rounded-xl border border-line bg-surface/80 p-7 shadow-overlay backdrop-blur-xl sm:p-8">
|
|
15
|
+
<span aria-hidden className="absolute inset-x-8 top-0 h-px bg-linear-to-r from-transparent via-accent-ink/60 to-transparent" />
|
|
16
|
+
{children ?? <div className="min-h-[22rem]" />}
|
|
17
|
+
</section>
|
|
18
|
+
);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** The demo's panel, when `DEMO_PASSWORD` is set: one password field and one primary action. */
|
|
22
|
+
export function PasswordPanel() {
|
|
23
|
+
const [state, action, pending] = useActionState(demoSignIn, { error: false });
|
|
24
|
+
return (
|
|
25
|
+
<PanelFrame>
|
|
26
|
+
<form action={action}>
|
|
27
|
+
<span className="grid size-11 place-items-center rounded-full bg-accent-tint text-accent-ink"><LockKeyhole className="size-5" /></span>
|
|
28
|
+
<h2 id="sign-in" className="t-section mt-5">Open the demo</h2>
|
|
29
|
+
<p className="t-small mt-2 text-ink-2">{app.name} on sample data. Enter the password you were given.</p>
|
|
30
|
+
<input type="text" name="username" value="demo" autoComplete="username" readOnly hidden />
|
|
31
|
+
<div className="mt-7 flex flex-col gap-4">
|
|
32
|
+
<Field label="Demo password" error={state.error ? 'That is not the demo password.' : undefined}>
|
|
33
|
+
{(p) => <Input {...p} name="password" type="password" size="lg" autoComplete="current-password" required autoFocus leading={<KeyRound className="size-4" />} />}
|
|
34
|
+
</Field>
|
|
35
|
+
<Button type="submit" variant="primary" size="lg" block busy={pending} trailing={<ArrowRight />}>Open the demo</Button>
|
|
36
|
+
</div>
|
|
37
|
+
</form>
|
|
38
|
+
</PanelFrame>
|
|
39
|
+
);
|
|
40
|
+
}
|
|
9
41
|
|
|
10
42
|
/** The sign-in panel: one field, one primary action, SSO beside it. A sent link replaces the form, never a toast. */
|
|
11
43
|
export function SignInPanel() {
|
|
@@ -27,8 +59,7 @@ export function SignInPanel() {
|
|
|
27
59
|
setState('sso');
|
|
28
60
|
};
|
|
29
61
|
return (
|
|
30
|
-
<
|
|
31
|
-
<span aria-hidden className="absolute inset-x-8 top-0 h-px bg-linear-to-r from-transparent via-accent-ink/60 to-transparent" />
|
|
62
|
+
<PanelFrame>
|
|
32
63
|
{state === 'sent' ? (
|
|
33
64
|
<div role="status">
|
|
34
65
|
<span className="grid size-11 place-items-center rounded-full bg-accent-tint text-accent-ink"><Mail className="size-5" /></span>
|
|
@@ -60,6 +91,6 @@ export function SignInPanel() {
|
|
|
60
91
|
<p className="t-caption mt-6 text-pretty">By continuing you agree to the Terms and the Privacy notice. We never post anything for you.</p>
|
|
61
92
|
</form>
|
|
62
93
|
)}
|
|
63
|
-
</
|
|
94
|
+
</PanelFrame>
|
|
64
95
|
);
|
|
65
96
|
}
|
|
@@ -54,8 +54,10 @@ this is the reason they agree.
|
|
|
54
54
|
refuses, keeping every backup, when a path it wrote was edited since.
|
|
55
55
|
- `--resume`, `--finalize` and `--abort` belong to the package version that began the session. They read the session's
|
|
56
56
|
own copies and never ask the registry.
|
|
57
|
-
- Updates start from 0.3.0.
|
|
58
|
-
|
|
57
|
+
- Updates start from 0.3.0. An update is tested from the last three releases, adopted and created: the release smoke
|
|
58
|
+
runs the release before through update and finalize, and the weekly smoke the last three. The window moves with each
|
|
59
|
+
release and never grows; from an older project, update in steps (`references/update.md`). Every published origin was
|
|
60
|
+
tested at release until 0.7.0, when the smoke took 11 of the release's 18 minutes and grew with each release.
|
|
59
61
|
|
|
60
62
|
### The access seam, scoped caching and live data
|
|
61
63
|
|
|
@@ -96,9 +98,9 @@ this is the reason they agree.
|
|
|
96
98
|
- Weekly is weekly, release is release, and nothing runs twice. The release gates, each once, on the default verify
|
|
97
99
|
from a clean `.next` on the GitHub-hosted ubuntu-24.04 4-CPU runner (at most 120 s, raised to 180 s in 0.6.0 because the runner's CPU varies, with its browser smoke; the gate
|
|
98
100
|
and its unit tests run inside it) and on the consumer smoke from the tarball: adopt, create with its default verify,
|
|
99
|
-
and
|
|
101
|
+
and the release before updated through finalize (the last three weekly). After publishing it checks the registry serves the tested
|
|
100
102
|
tarball (equal sha256) with provenance, and tags last. A weekly workflow runs `verify --full --perf` (the perf part a
|
|
101
|
-
report) and the consumer smoke's adopted default-verify cases and recovery and failure cases; a failure notifies and
|
|
103
|
+
report) and the consumer smoke's adopted default-verify cases, the update from the last three releases, and its recovery and failure cases; a failure notifies and
|
|
102
104
|
nothing waits on it.
|
|
103
105
|
|
|
104
106
|
### Every breaking interface, with its migration
|
|
@@ -4,12 +4,16 @@ Status: v1 (`create`, `adopt`, `skill`) shipped in 0.2.0 (decision 0009). v2 (`u
|
|
|
4
4
|
|
|
5
5
|
## The one sentence
|
|
6
6
|
|
|
7
|
-
A team gives its coding agent this,
|
|
7
|
+
A team gives its coding agent this, with what it wants in its own words at the end:
|
|
8
8
|
|
|
9
|
-
> Run `npx zz-meridian@latest
|
|
10
|
-
> routes, restyle every page with Meridian's components and tokens, and run pnpm verify until it passes.
|
|
9
|
+
> Run `npx zz-meridian@latest skill --global`, then follow the zz-meridian skill it installs to: [what you want, in your own words].
|
|
11
10
|
|
|
12
|
-
|
|
11
|
+
The sentence names no command, because people do not: "change this product into our dashboard", "a new one based on
|
|
12
|
+
this folder, in another folder" and "build an orders console" all ask for a dashboard on Meridian and differ only in
|
|
13
|
+
which folder holds it. The skill's "Choose the route" table maps what was said, and what is in the folder, to `adopt`
|
|
14
|
+
(this Next.js App Router project, in place), `create` (a new folder; a folder named as the source is read, never
|
|
15
|
+
written) or `update` and `brand` (a project that already has Meridian), and says the route before the first command.
|
|
16
|
+
An earlier sentence named `adopt`, the wrong command for every request for a new dashboard.
|
|
13
17
|
|
|
14
18
|
The split follows what each part is good at. The package does the settled work, the same way every time, and proves it
|
|
15
19
|
built: what to copy, which dependencies to merge, the alias, the stylesheet, the brand. The agent does the judgement:
|
|
@@ -214,13 +218,17 @@ Modelled on the release pipeline of the owner's earlier packages, one package in
|
|
|
214
218
|
- `adopt` into `cli/test/fixture-next-app` (a minimal App Router app with one page and its own stylesheet), then
|
|
215
219
|
install, `tsc --noEmit` and `next build`, all green. This is the test that keeps the Route A list complete.
|
|
216
220
|
- `create` into a clean folder, then its default `pnpm verify`. The smoke quotes the coverage line.
|
|
217
|
-
- `update` from
|
|
218
|
-
|
|
221
|
+
- `update` from the release before (adopted and created, read from the registry): a dry-run that writes nothing, the
|
|
222
|
+
update, what it reports resolved, finalize with the gate and the build, and the team's bytes unchanged. One origin
|
|
223
|
+
at release and the last three weekly, so the time stays the same however many releases there are; the updater's
|
|
224
|
+
own stages and each migration have their tests (`--update-all`, `tests/cli-*`).
|
|
219
225
|
5. **Publish** the tarball with `npm` 11.5.1 or newer through trusted publishing (OIDC), with `--provenance`. `pnpm
|
|
220
226
|
publish` does not perform the OIDC exchange.
|
|
221
227
|
6. **The registry's package**: `npx zz-meridian@<version> --version` answers the version, the registry's tarball has the
|
|
222
|
-
tested tarball's sha256, and it carries a provenance attestation. The bytes passed step 4 already
|
|
223
|
-
|
|
228
|
+
tested tarball's sha256, and it carries a provenance attestation. The bytes passed step 4 already; two paths still
|
|
229
|
+
change once the version is published, and run here, in about 10 seconds: `create` from the registry (npm renames a
|
|
230
|
+
packed `.gitignore` on install, so the project must have one), and `update` to the version in a project created
|
|
231
|
+
with the release before (it compares the running package with the registry's only once that one exists). A failure here is an unsuccessful release, not a rollback: the version stays published and untagged
|
|
224
232
|
until a fix is released.
|
|
225
233
|
7. **Tag `v<version>` last**, then the GitHub Release with the version's `CHANGELOG.md` section as its body.
|
|
226
234
|
|
|
@@ -228,8 +236,10 @@ Modelled on the release pipeline of the owner's earlier packages, one package in
|
|
|
228
236
|
|
|
229
237
|
**Weekly** (`.github/workflows/weekly.yml`, Mondays and on demand, master's current commit): the template's
|
|
230
238
|
`verify --full --perf`, one gate and one build for both (the perf part a report: a p95 over budget is a warning, a
|
|
231
|
-
broken sample a failure), and the consumer smoke's `--adopt --update-all --verify` from a tarball packed from that
|
|
232
|
-
commit
|
|
239
|
+
broken sample a failure), and the consumer smoke's `--adopt --update --origins 3 --update-all --verify` from a tarball packed from that
|
|
240
|
+
commit, labelled as the next patch so `update` treats it as unpublished: the adopted project's default verify cases,
|
|
241
|
+
the update from each of the last three releases, the updater's stages on the 0.3.0 fixture, and every recovery and
|
|
242
|
+
failure case of the update. Weekly is weekly
|
|
233
243
|
and release is release: neither runs what the other does. Each job keeps its log as an artifact. A failure notifies;
|
|
234
244
|
nothing waits on it.
|
|
235
245
|
|
package/payload/gitignore
CHANGED
|
@@ -28,6 +28,16 @@ test-results
|
|
|
28
28
|
# deployment
|
|
29
29
|
.vercel
|
|
30
30
|
|
|
31
|
+
# zz-meridian: an update in progress (its staged copies, backups and lock). .meridian/manifest.json, keep.json and
|
|
32
|
+
# history/ are committed: the next update reads them.
|
|
33
|
+
/.meridian/update/
|
|
34
|
+
/.meridian/update.lock
|
|
35
|
+
|
|
36
|
+
# agents: each person's own settings and runtime files. The skills under .claude/skills and .agents/skills are
|
|
37
|
+
# committed: they are the version of the skill that matches this project's Meridian, and update keeps them so.
|
|
38
|
+
/.claude/settings.local.json
|
|
39
|
+
/.claude/*.lock
|
|
40
|
+
|
|
31
41
|
# logs
|
|
32
42
|
*-debug.log*
|
|
33
43
|
*-error.log*
|
package/payload/next.config.ts
CHANGED
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
import type { NextConfig } from 'next';
|
|
2
2
|
|
|
3
|
+
// Next.js's anonymous usage telemetry is off for `next dev` and `next build`; delete this line to send it.
|
|
4
|
+
process.env.NEXT_TELEMETRY_DISABLED ??= '1';
|
|
5
|
+
|
|
3
6
|
const config: NextConfig = {
|
|
4
7
|
reactStrictMode: true,
|
|
5
8
|
cacheComponents: true,
|
package/payload/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "zz-meridian-template",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.8.0",
|
|
4
4
|
"private": true,
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"description": "ZZ Meridian: a dashboard design system and starter. DTCG tokens, five layers of React components and a Design Atlas, in a light and a dark theme.",
|
package/payload/proxy.ts
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { NextResponse, type NextRequest } from 'next/server';
|
|
2
|
+
import { COOKIE, MAX_AGE, expiresOf, gated, token } from '@/lib/demo-gate';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The demo's password gate (`src/lib/demo-gate.ts`), open unless `DEMO_PASSWORD` is set. Every route needs a live
|
|
6
|
+
* session except the sign-in page and the files a browser fetches for it; an API answers 401 instead of redirecting.
|
|
7
|
+
* Opening the sign-in page signs the demo out, which is what the rail's Sign out does. A visit past half the session's
|
|
8
|
+
* life renews it.
|
|
9
|
+
*/
|
|
10
|
+
export function proxy(req: NextRequest) {
|
|
11
|
+
if (!gated()) return NextResponse.next();
|
|
12
|
+
const { pathname } = req.nextUrl;
|
|
13
|
+
const value = req.cookies.get(COOKIE)?.value;
|
|
14
|
+
if (pathname === '/sign-in') {
|
|
15
|
+
const res = NextResponse.next();
|
|
16
|
+
// A POST here is the sign-in itself, which sets the session; only a visit clears it.
|
|
17
|
+
if (req.method === 'GET' && value) res.cookies.set(COOKIE, '', { path: '/', maxAge: 0 });
|
|
18
|
+
return res;
|
|
19
|
+
}
|
|
20
|
+
const expires = expiresOf(value);
|
|
21
|
+
if (!expires) {
|
|
22
|
+
if (pathname.startsWith('/api/')) return NextResponse.json({ error: 'Sign in to the demo first.' }, { status: 401 });
|
|
23
|
+
const to = req.nextUrl.clone();
|
|
24
|
+
to.pathname = '/sign-in';
|
|
25
|
+
to.search = '';
|
|
26
|
+
return NextResponse.redirect(to, 303);
|
|
27
|
+
}
|
|
28
|
+
const res = NextResponse.next();
|
|
29
|
+
if (expires - Date.now() / 1000 < MAX_AGE / 2) {
|
|
30
|
+
res.cookies.set(COOKIE, token(), { httpOnly: true, path: '/', sameSite: 'lax', maxAge: MAX_AGE, secure: req.headers.get('x-forwarded-proto') === 'https' });
|
|
31
|
+
}
|
|
32
|
+
return res;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export const config = {
|
|
36
|
+
matcher: ['/((?!_next/static|_next/image|favicon|icon|apple-icon).*)'],
|
|
37
|
+
};
|
package/payload/scripts/check.ts
CHANGED
|
@@ -281,11 +281,12 @@ const retirements: Retirement[] = [];
|
|
|
281
281
|
}
|
|
282
282
|
|
|
283
283
|
// ── The template's request boundaries ────────────────────────────────────────────────────────────────
|
|
284
|
-
//
|
|
285
|
-
// the address in
|
|
284
|
+
// Three places read the request: the console layout, which hands the shell a promise, the not-found page, which streams
|
|
285
|
+
// the address in, and the sign-in page's panel, which streams in as the demo's password or the product's sign-in, set by
|
|
286
|
+
// DEMO_PASSWORD at run time. A `connection()` anywhere else makes the page behind it wait for the request, and a template whose
|
|
286
287
|
// pages all prerender has none. In a product those pages are the team's, so the rule is the template's alone.
|
|
287
288
|
if (!fs.existsSync(manifestFile)) {
|
|
288
|
-
const BOUNDARIES = [`${APP_DIR}/(dashboard)/layout.tsx`, `${APP_DIR}/not-found.tsx`];
|
|
289
|
+
const BOUNDARIES = [`${APP_DIR}/(dashboard)/layout.tsx`, `${APP_DIR}/not-found.tsx`, `${APP_DIR}/sign-in/gated-panel.tsx`];
|
|
289
290
|
/** Comments blanked, not removed, so a line number is the file's. */
|
|
290
291
|
const blanked = (f: string) => read(f).replace(/\/\*[\s\S]*?\*\//g, (c) => c.replace(/[^\n]/g, ' ')).replace(/(^|[^:])\/\/[^\n]*/g, '$1');
|
|
291
292
|
const lineOf = (src: string, at: number) => src.slice(0, at).split('\n').length;
|
|
@@ -293,7 +294,7 @@ if (!fs.existsSync(manifestFile)) {
|
|
|
293
294
|
for (const f of sources) {
|
|
294
295
|
if (BOUNDARIES.includes(f)) continue;
|
|
295
296
|
blanked(f).split('\n').forEach((line, i) => {
|
|
296
|
-
if (/\bconnection\s*\(/.test(line)) problems.push(`${f}:${i + 1}: connection() outside the template's
|
|
297
|
+
if (/\bconnection\s*\(/.test(line)) problems.push(`${f}:${i + 1}: connection() outside the template's three request boundaries`);
|
|
297
298
|
});
|
|
298
299
|
}
|
|
299
300
|
// A page and a view read the sample through src/data, so the day it becomes an API is one file's change. Every
|
|
@@ -339,7 +340,8 @@ const TOOLKIT = /^src\/lib\/(format|color|collection|live)\.ts$/;
|
|
|
339
340
|
// release may add an export the team's code does not use yet. Only the project's own src/lib and src/data are swept.
|
|
340
341
|
// The template, with no manifest, is swept whole.
|
|
341
342
|
const meridians = new Set(Object.keys(effective?.files ?? {}));
|
|
342
|
-
|
|
343
|
+
// A root proxy.ts is the product's own code too: Next runs it before every request.
|
|
344
|
+
const kept = [...['src', 'app', 'scripts'].flatMap((d) => walk(d, /\.tsx?$/)), ...(fs.existsSync(path.join(ROOT, 'proxy.ts')) ? ['proxy.ts'] : [])].filter((f) => !/(^|\/)preview\.tsx$/.test(f) && !f.startsWith('app/system/') && !atlasOnly.includes(f));
|
|
343
345
|
const resolveSpec = (from: string, spec: string) => {
|
|
344
346
|
const base = spec.startsWith('@/') ? path.join('src', spec.slice(2)) : spec.startsWith('.') ? path.join(path.dirname(from), spec) : null;
|
|
345
347
|
if (!base) return null;
|
|
@@ -5,4 +5,8 @@ import path from 'node:path';
|
|
|
5
5
|
|
|
6
6
|
const ROOT = path.resolve(import.meta.dirname, '../..');
|
|
7
7
|
|
|
8
|
+
// The gate, verify and the live check run `next` in the project: Meridian's own runs send no Next.js telemetry, whatever
|
|
9
|
+
// the project's next.config says.
|
|
10
|
+
process.env.NEXT_TELEMETRY_DISABLED ??= '1';
|
|
11
|
+
|
|
8
12
|
export const bin = (name: string) => path.join(ROOT, 'node_modules', '.bin', process.platform === 'win32' ? `${name}.cmd` : name);
|
|
@@ -38,6 +38,35 @@ These hold in every project, however it started, and nothing below overrides the
|
|
|
38
38
|
API or read a database names a `fakeApi` (or says `noLiveApi`) in `scripts/verify.config.ts` first
|
|
39
39
|
(`references/existing-project.md`, step 5).
|
|
40
40
|
|
|
41
|
+
## Choose the route before anything else
|
|
42
|
+
|
|
43
|
+
Whatever the words, the person wants one thing: a dashboard on Meridian. What you decide is **where its code lives**
|
|
44
|
+
and **which folder you may write to**, and that picks one of four commands. People rarely name the command, so read
|
|
45
|
+
the intent, not the verb: "change this product into our new dashboard", "redo this with Meridian", "make a new one
|
|
46
|
+
based on this folder in another folder" and "build me an orders console" are all requests for a dashboard; they
|
|
47
|
+
differ only in which folder ends up holding it.
|
|
48
|
+
|
|
49
|
+
| What is true | The route | The command |
|
|
50
|
+
|---|---|---|
|
|
51
|
+
| `optional:.meridian/manifest.json` exists in the folder they point at | Already on Meridian: update, rebrand or keep building | `update` or `brand` (the next section), or straight to step 5 |
|
|
52
|
+
| They want **this** project changed in place ("restyle this", "change this product into our dashboard", "make our admin look professional"), and it is Next.js with the App Router | Adopt, here | `npx zz-meridian@latest adopt` (`references/existing-project.md`, Route A) |
|
|
53
|
+
| They want this project changed, and it is another stack (Vite, CRA, Remix, Vue, a static page) | A new project next to it, ported from it | `create <sibling folder>` (`references/existing-project.md`, Routes A2, B, C) |
|
|
54
|
+
| They want a **new** dashboard: in another folder, "based on" or "from" this folder, a schema, a CSV, a spec or nothing | Create, elsewhere; what they pointed at is input you read, never a folder you write | `npx zz-meridian@latest create <new folder>` |
|
|
55
|
+
|
|
56
|
+
Rules that settle the hard cases:
|
|
57
|
+
|
|
58
|
+
- **The folder they name to read from is not the folder you write to unless they said "this", "here" or "in place".**
|
|
59
|
+
"Based on this folder", "use this as the source", "copy what this does" mean: read it, create next to it, leave it
|
|
60
|
+
untouched.
|
|
61
|
+
- **`create` writes only into a folder that does not exist or is empty**, and **`adopt` changes the project it runs in**.
|
|
62
|
+
Never run `adopt` in a folder they asked you to leave alone, and never `create` inside an existing project.
|
|
63
|
+
- **Two routes fit and nothing decides between them** (an existing Next.js app here and "build me a dashboard for
|
|
64
|
+
this"): ask once, with in place (`adopt`) as the recommended option when they said "this" or "our", and a new folder
|
|
65
|
+
(`create`) when they said "new" or "another". With no way to ask, take that recommendation and say so in the
|
|
66
|
+
hand-over.
|
|
67
|
+
- **Say the route in one sentence before the first command**: "This is a Next.js App Router project you want changed
|
|
68
|
+
in place, so I am running `adopt` here." A wrong route is cheap to stop before the command and costly after it.
|
|
69
|
+
|
|
41
70
|
## Updating or rebranding a project that already has Meridian
|
|
42
71
|
|
|
43
72
|
When `optional:.meridian/manifest.json` exists and the person asks to update Meridian, or to change the brand, do not
|
|
@@ -70,8 +99,8 @@ Before asking anything, look at what you already have:
|
|
|
70
99
|
Ask in a single round (AskUserQuestion when available), only the questions your homework could not answer, and offer
|
|
71
100
|
your draft as the recommended option so the person can accept it in one click. Usually that is:
|
|
72
101
|
|
|
73
|
-
1. **New project or this one?** Only when
|
|
74
|
-
bring Meridian into the existing one (`references/existing-project.md`).
|
|
102
|
+
1. **New project or this one?** Only when "Choose the route" above left two routes open: create a new Meridian project
|
|
103
|
+
in another folder, or bring Meridian into the existing one (`references/existing-project.md`).
|
|
75
104
|
2. **What it is and for whom.** The product name, who uses it, and the one question the home page must answer.
|
|
76
105
|
3. **Pages.** Your drafted list, mapped to Meridian presets (Overview, list, detail, analytics, health, settings, sign-in).
|
|
77
106
|
4. **Brand and surfaces.** A brand colour (a hex, or "no preference" for indigo), light or dark first (dark is the
|
|
@@ -230,49 +259,57 @@ Pages: <list, one line each, with what each answers>.
|
|
|
230
259
|
Brand: <accent and how it was derived>. Surfaces: console, mobile<, MCP views: …>.
|
|
231
260
|
Validation: pnpm verify passed; coverage line: <the line verify printed>.
|
|
232
261
|
Next steps: replace the sample data in src/data/ with <their API>; run pnpm verify after every change.
|
|
233
|
-
Feedback: <the issue URL from step 8,
|
|
262
|
+
Feedback: <the issue URL from step 8, "nothing to report", or "declined">.
|
|
234
263
|
```
|
|
235
264
|
|
|
236
265
|
Attach or show the screenshots of the main pages in both themes. Say plainly what is sample data and what is not. If
|
|
237
266
|
the Atlas stays, say that `/system` is the live specification and that `node scripts/brand.ts --no-atlas` removes it
|
|
238
267
|
before the product goes public.
|
|
239
268
|
|
|
240
|
-
## 8.
|
|
269
|
+
## 8. Offer feedback to Meridian
|
|
241
270
|
|
|
242
|
-
|
|
243
|
-
|
|
271
|
+
Meridian collects nothing from the people who use it: no telemetry, no analytics, no account. A GitHub issue the person
|
|
272
|
+
chooses to open is the only way anything reaches Meridian, so this step is an offer, never a requirement, and it
|
|
273
|
+
happens only when the run found something about Meridian itself.
|
|
244
274
|
|
|
245
|
-
Collect, from the whole session (not only the last step):
|
|
275
|
+
Collect, from the whole session (not only the last step), and sort each finding into one of two kinds:
|
|
246
276
|
|
|
247
|
-
- **
|
|
277
|
+
- **Bug**: a component, pattern, script, check or doc that did the wrong thing: a gate that failed on correct code, a
|
|
248
278
|
check that passed broken code, a component that clipped, overflowed or did nothing, a doc that sent you the wrong way.
|
|
249
|
-
- **
|
|
250
|
-
|
|
251
|
-
|
|
279
|
+
- **Feature request**: anything that worked but cost a workaround, a second try or a guess, and the change that would
|
|
280
|
+
have saved it; and what the product needed that Meridian has no answer for (a component, pattern, state, page kind,
|
|
281
|
+
surface rule, or a question this skill and the docs never answered).
|
|
252
282
|
|
|
253
|
-
Each
|
|
254
|
-
command), how to see it again
|
|
255
|
-
|
|
283
|
+
Each finding gets what someone needs to act on it without asking: what happened, where in Meridian (its `file:line`,
|
|
284
|
+
the Meridian command, the Meridian route), how to see it again in Meridian's own template or sample data, and what you
|
|
285
|
+
did instead.
|
|
256
286
|
|
|
257
|
-
|
|
287
|
+
**Nothing in it may identify the person, their organisation or their product.** Before showing a draft, remove:
|
|
258
288
|
|
|
259
|
-
|
|
260
|
-
|
|
289
|
+
- the product's, company's, team's and people's names, email addresses and accounts;
|
|
290
|
+
- their URLs, hostnames, IP addresses, API routes and environment variable values;
|
|
291
|
+
- their data, records, schemas, field names and screenshots of their pages;
|
|
292
|
+
- file paths outside Meridian's own files, and anything from `optional:docs/brief.md`.
|
|
293
|
+
|
|
294
|
+
Describe the shape of the problem with Meridian's sample instead ("a Data table with 40 columns", not their table). If
|
|
295
|
+
a finding cannot be told without one of these, leave it out.
|
|
261
296
|
|
|
262
|
-
|
|
297
|
+
Draft one issue per kind, in the shape of Meridian's two issue forms, Bug and Feature request:
|
|
298
|
+
|
|
299
|
+
```
|
|
300
|
+
Title: <one line on the most important finding>
|
|
263
301
|
|
|
264
|
-
|
|
265
|
-
- <what, where, how to reproduce, what you did instead>
|
|
302
|
+
Meridian <version from .meridian/manifest.json> · Next <version> · Route <adopt | create>
|
|
266
303
|
|
|
267
|
-
##
|
|
268
|
-
- <what
|
|
304
|
+
## What happened
|
|
305
|
+
- <finding: what, where in Meridian, how to see it again, what you did instead>
|
|
269
306
|
|
|
270
|
-
##
|
|
271
|
-
- <
|
|
307
|
+
## What would fix it
|
|
308
|
+
- <the change you would make to Meridian>
|
|
272
309
|
```
|
|
273
310
|
|
|
274
|
-
Show the draft to the person and
|
|
275
|
-
`gh issue create --repo zhixuan312/zz-meridian --title "<title>" --body-file
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
311
|
+
Show the draft to the person and ask whether to open it: it is published under their account, in public. Only on a
|
|
312
|
+
yes, file it with `gh issue create --repo zhixuan312/zz-meridian --label bug|enhancement --title "<title>" --body-file
|
|
313
|
+
<draft>`, or, without `gh`, give them `https://github.com/zhixuan312/zz-meridian/issues/new/choose` and the draft to
|
|
314
|
+
paste. Put the issue's URL in the hand-over. When the run found nothing about Meridian, or they decline, say so in one
|
|
315
|
+
line and file nothing.
|
|
@@ -5,6 +5,10 @@ Meridian would change, and which of those changes need the team's decision. It w
|
|
|
5
5
|
`npx zz-meridian@latest update` then applies the safe changes and stages the rest. Do not copy files by hand to make up
|
|
6
6
|
for either.
|
|
7
7
|
|
|
8
|
+
An update is tested from the last three releases. When the manifest's version is older than that (`npm view
|
|
9
|
+
zz-meridian versions` lists them), update in steps: first with the release three after it,
|
|
10
|
+
`npx zz-meridian@<that version> update`, through finalize, then again with `latest`.
|
|
11
|
+
|
|
8
12
|
## What it does
|
|
9
13
|
|
|
10
14
|
It works from `optional:.meridian/manifest.json`, which records the release the project was copied from and a hash of
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A password in front of the whole product, for a demo deployment: an HttpOnly cookie holding an expiry and its HMAC.
|
|
3
|
+
* It is closed only when `DEMO_PASSWORD` is set; without it (local development, and every product that never sets it)
|
|
4
|
+
* nothing is gated. The key is derived from `DEMO_SECRET` (or, without it, from `DEMO_PASSWORD`), so a session survives
|
|
5
|
+
* restarts and redeploys, and changing either signs everyone out. A session lasts 30 days and is renewed when a visit
|
|
6
|
+
* finds it past half its life. Server only.
|
|
7
|
+
*/
|
|
8
|
+
import crypto from 'node:crypto';
|
|
9
|
+
|
|
10
|
+
export const COOKIE = 'zz_meridian_demo';
|
|
11
|
+
export const MAX_AGE = 30 * 24 * 3600;
|
|
12
|
+
|
|
13
|
+
const password = () => process.env.DEMO_PASSWORD ?? '';
|
|
14
|
+
/** Whether the gate is closed: only when a password is configured. */
|
|
15
|
+
export const gated = () => password() !== '';
|
|
16
|
+
const key = () => crypto.createHmac('sha256', process.env.DEMO_SECRET || password()).update('zz-meridian-demo-session-v1').digest();
|
|
17
|
+
const sign = (expires: number | string) => crypto.createHmac('sha256', key()).update(String(expires)).digest('hex');
|
|
18
|
+
|
|
19
|
+
/** A fresh session value: expiry.signature. */
|
|
20
|
+
export function token(): string {
|
|
21
|
+
const expires = Math.floor(Date.now() / 1000) + MAX_AGE;
|
|
22
|
+
return `${expires}.${sign(expires)}`;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** The session's expiry in seconds when the cookie value is genuine and unexpired, else 0. */
|
|
26
|
+
export function expiresOf(value: string | undefined): number {
|
|
27
|
+
const m = value?.match(/^([0-9]+)\.([0-9a-f]{64})$/);
|
|
28
|
+
if (!m || Number(m[1]) < Date.now() / 1000) return 0;
|
|
29
|
+
const a = Buffer.from(m[2]!, 'hex');
|
|
30
|
+
const b = Buffer.from(sign(m[1]!), 'hex');
|
|
31
|
+
return a.length === b.length && crypto.timingSafeEqual(a, b) ? Number(m[1]) : 0;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Compares with the configured password in constant time, whatever the lengths. */
|
|
35
|
+
export function samePassword(given: string): boolean {
|
|
36
|
+
const a = crypto.createHash('sha256').update(given).digest();
|
|
37
|
+
const b = crypto.createHash('sha256').update(password()).digest();
|
|
38
|
+
return gated() && crypto.timingSafeEqual(a, b);
|
|
39
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
// @vitest-environment node
|
|
2
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
3
|
+
import { NextRequest } from 'next/server';
|
|
4
|
+
import { COOKIE, MAX_AGE, expiresOf, gated, samePassword, token } from '@/lib/demo-gate';
|
|
5
|
+
import { proxy } from '../proxy';
|
|
6
|
+
|
|
7
|
+
const request = (path: string, { cookie, method = 'GET' }: { cookie?: string; method?: string } = {}) =>
|
|
8
|
+
new NextRequest(`https://demo.example${path}`, { method, headers: cookie ? { cookie: `${COOKIE}=${cookie}` } : {} });
|
|
9
|
+
|
|
10
|
+
afterEach(() => { delete process.env.DEMO_PASSWORD; delete process.env.DEMO_SECRET; });
|
|
11
|
+
|
|
12
|
+
describe('the demo gate, without DEMO_PASSWORD', () => {
|
|
13
|
+
it('is open: every route passes and no password matches', () => {
|
|
14
|
+
expect(gated()).toBe(false);
|
|
15
|
+
expect(samePassword('')).toBe(false);
|
|
16
|
+
expect(proxy(request('/')).headers.get('location')).toBeNull();
|
|
17
|
+
expect(proxy(request('/api/export/requests')).status).toBe(200);
|
|
18
|
+
});
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
describe('the demo gate, with DEMO_PASSWORD', () => {
|
|
22
|
+
beforeEach(() => { process.env.DEMO_PASSWORD = 'open sesame'; });
|
|
23
|
+
|
|
24
|
+
it('accepts the password and nothing else', () => {
|
|
25
|
+
expect(samePassword('open sesame')).toBe(true);
|
|
26
|
+
expect(samePassword('open sesam')).toBe(false);
|
|
27
|
+
expect(samePassword('')).toBe(false);
|
|
28
|
+
});
|
|
29
|
+
it('signs a session it can read back, and refuses a forged, an expired or a re-keyed one', () => {
|
|
30
|
+
const t = token();
|
|
31
|
+
expect(expiresOf(t)).toBeGreaterThan(Date.now() / 1000 + MAX_AGE - 5);
|
|
32
|
+
expect(expiresOf(`${Number(t.split('.')[0]) + 1}.${t.split('.')[1]}`)).toBe(0);
|
|
33
|
+
expect(expiresOf(`1.${t.split('.')[1]}`)).toBe(0);
|
|
34
|
+
expect(expiresOf(undefined)).toBe(0);
|
|
35
|
+
process.env.DEMO_SECRET = 'rotated';
|
|
36
|
+
expect(expiresOf(t)).toBe(0);
|
|
37
|
+
});
|
|
38
|
+
it('sends a visitor without a session to the sign-in page, and answers an API with 401', () => {
|
|
39
|
+
const page = proxy(request('/system'));
|
|
40
|
+
expect(page.status).toBe(303);
|
|
41
|
+
expect(new URL(page.headers.get('location')!).pathname).toBe('/sign-in');
|
|
42
|
+
expect(proxy(request('/api/assistant', { method: 'POST' })).status).toBe(401);
|
|
43
|
+
});
|
|
44
|
+
it('lets a session through, and renews one past half its life', () => {
|
|
45
|
+
const fresh = proxy(request('/', { cookie: token() }));
|
|
46
|
+
expect(fresh.headers.get('location')).toBeNull();
|
|
47
|
+
expect(fresh.cookies.get(COOKIE)).toBeUndefined();
|
|
48
|
+
vi.useFakeTimers();
|
|
49
|
+
try {
|
|
50
|
+
const t = token();
|
|
51
|
+
vi.setSystemTime(Date.now() + 20 * 24 * 3600 * 1000);
|
|
52
|
+
const renewed = proxy(request('/', { cookie: t })).cookies.get(COOKIE)?.value;
|
|
53
|
+
expect(renewed).not.toBe(t);
|
|
54
|
+
expect(expiresOf(renewed)).toBeGreaterThan(expiresOf(t));
|
|
55
|
+
} finally {
|
|
56
|
+
vi.useRealTimers();
|
|
57
|
+
}
|
|
58
|
+
});
|
|
59
|
+
it('signs the demo out when the sign-in page is opened, and leaves the sign-in itself alone', () => {
|
|
60
|
+
expect(proxy(request('/sign-in')).status).toBe(200);
|
|
61
|
+
expect(proxy(request('/sign-in', { cookie: token() })).cookies.get(COOKIE)?.value).toBe('');
|
|
62
|
+
expect(proxy(request('/sign-in', { cookie: token(), method: 'POST' })).cookies.get(COOKIE)).toBeUndefined();
|
|
63
|
+
});
|
|
64
|
+
});
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
// @vitest-environment node
|
|
2
|
+
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
3
|
+
|
|
4
|
+
beforeEach(() => { vi.resetModules(); delete process.env.NEXT_TELEMETRY_DISABLED; });
|
|
5
|
+
|
|
6
|
+
describe('Next.js telemetry', () => {
|
|
7
|
+
it("is off in the template's next.config, which next dev and next build load before they send any", async () => {
|
|
8
|
+
await import('../next.config.ts');
|
|
9
|
+
expect(process.env.NEXT_TELEMETRY_DISABLED).toBe('1');
|
|
10
|
+
});
|
|
11
|
+
it("is off for every next the gate, verify and the live check run, whatever the project's own config says", async () => {
|
|
12
|
+
await import('../scripts/lib/bin.ts');
|
|
13
|
+
expect(process.env.NEXT_TELEMETRY_DISABLED).toBe('1');
|
|
14
|
+
});
|
|
15
|
+
});
|