zz-meridian 0.3.0 → 0.5.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 +31 -4
- package/dist/adopt.js +59 -24
- package/dist/cli.js +36 -0
- package/dist/context.js +82 -0
- package/dist/create.js +27 -3
- package/dist/files.js +8 -1
- package/dist/inventory.js +131 -0
- package/dist/merge-report.js +87 -0
- package/dist/migrations.js +220 -0
- package/dist/ownership.js +112 -0
- package/dist/rebrand.js +212 -0
- package/dist/reconcile.js +192 -0
- package/dist/replay.js +204 -0
- package/dist/session.js +910 -0
- package/dist/update-session.js +292 -0
- package/dist/update.js +187 -0
- package/package.json +1 -1
- package/payload/AGENTS.md +1 -1
- package/payload/CHANGELOG.md +211 -1
- package/payload/CONTRIBUTING.md +11 -7
- package/payload/README.md +14 -9
- package/payload/app/(dashboard)/{README.md → (overview)/README.md} +3 -3
- package/payload/app/(dashboard)/(overview)/loading.tsx +52 -0
- package/payload/app/(dashboard)/(overview)/page.tsx +52 -0
- package/payload/app/(dashboard)/_loading.tsx +73 -0
- package/payload/app/(dashboard)/analytics/README.md +2 -2
- package/payload/app/(dashboard)/analytics/loading.tsx +23 -0
- package/payload/app/(dashboard)/analytics/page.tsx +29 -9
- package/payload/app/(dashboard)/customers/README.md +5 -1
- package/payload/app/(dashboard)/customers/loading.tsx +11 -0
- package/payload/app/(dashboard)/customers/page.tsx +23 -5
- package/payload/app/(dashboard)/health/README.md +4 -3
- package/payload/app/(dashboard)/health/loading.tsx +33 -0
- package/payload/app/(dashboard)/health/page.tsx +4 -4
- package/payload/app/(dashboard)/keys/README.md +1 -0
- package/payload/app/(dashboard)/keys/actions.ts +17 -7
- package/payload/app/(dashboard)/keys/loading.tsx +12 -0
- package/payload/app/(dashboard)/keys/page.tsx +4 -5
- package/payload/app/(dashboard)/layout.tsx +11 -7
- package/payload/app/(dashboard)/members/README.md +1 -0
- package/payload/app/(dashboard)/members/actions.ts +23 -11
- package/payload/app/(dashboard)/members/loading.tsx +10 -0
- package/payload/app/(dashboard)/members/page.tsx +4 -5
- package/payload/app/(dashboard)/requests/README.md +11 -3
- package/payload/app/(dashboard)/requests/[id]/README.md +1 -0
- package/payload/app/(dashboard)/requests/[id]/loading.tsx +23 -0
- package/payload/app/(dashboard)/requests/[id]/page.tsx +9 -6
- package/payload/app/(dashboard)/requests/loading.tsx +11 -0
- package/payload/app/(dashboard)/requests/page.tsx +63 -8
- package/payload/app/(dashboard)/settings/README.md +4 -2
- package/payload/app/(dashboard)/settings/loading.tsx +19 -0
- package/payload/app/api/assistant/route.ts +20 -3
- package/payload/app/api/export/requests/route.ts +59 -0
- package/payload/app/api/live/route.ts +32 -0
- package/payload/app/embed/health/page.tsx +1 -2
- package/payload/app/embed/overview/page.tsx +11 -2
- package/payload/app/embed/overview/view.tsx +1 -1
- package/payload/app/embed/requests/README.md +2 -2
- package/payload/app/embed/requests/page.tsx +13 -4
- package/payload/app/embed/requests/view.tsx +40 -28
- package/payload/app/not-found/README.md +1 -1
- package/payload/app/not-found.tsx +33 -4
- package/payload/app/sign-in/page.tsx +1 -1
- package/payload/app/system/(atlas)/[section]/[id]/page.tsx +12 -61
- package/payload/app/system/(atlas)/_article.tsx +83 -0
- package/payload/app/system/(atlas)/start/[id]/page.tsx +9 -0
- package/payload/app/system/(atlas)/system/[id]/page.tsx +9 -0
- package/payload/app/system/preview/[section]/[card]/keys.ts +76 -0
- package/payload/app/system/preview/[section]/[card]/page.tsx +12 -5
- package/payload/app/system/preview/[section]/[card]/stage.tsx +7 -7
- package/payload/app/system/states/layout.tsx +1 -1
- package/payload/app/system/states/loading/page.tsx +2 -2
- package/payload/decisions/0001-react-and-the-repository.md +1 -1
- package/payload/decisions/{0002-zandro-register.md → 0002-the-register.md} +3 -3
- package/payload/decisions/0006-quiet-light.md +1 -1
- package/payload/decisions/0007-the-name.md +1 -1
- package/payload/decisions/0009-a-package-that-copies.md +2 -2
- package/payload/decisions/0010-the-adopter-contract.md +145 -0
- package/payload/docs/assistant.md +10 -3
- package/payload/docs/benchmark.md +7 -7
- package/payload/docs/distribution.md +136 -34
- package/payload/docs/evidence/0.5.0.md +76 -0
- package/payload/docs/start-a-dashboard.md +11 -8
- package/payload/docs/surfaces.md +10 -5
- package/payload/next.config.ts +3 -1
- package/payload/package.json +1 -1
- package/payload/scripts/assistant.ts +9 -0
- package/payload/scripts/audit.ts +10 -8
- package/payload/scripts/brand.ts +34 -50
- package/payload/scripts/check.ts +202 -6
- package/payload/scripts/contrast.ts +4 -4
- package/payload/scripts/fake-llm.ts +1 -2
- package/payload/scripts/gate.ts +8 -3
- package/payload/scripts/interactions.ts +6 -4
- package/payload/scripts/keyboard.ts +18 -3
- package/payload/scripts/lib/budgets.ts +70 -0
- package/payload/scripts/lib/chrome.ts +23 -2
- package/payload/scripts/lib/context-check.ts +88 -0
- package/payload/scripts/lib/coverage.ts +75 -0
- package/payload/scripts/lib/next-config.ts +20 -0
- package/payload/scripts/lib/routes.ts +23 -2
- package/payload/scripts/lib/sizes.ts +57 -0
- package/payload/scripts/lib/timing.ts +33 -0
- package/payload/scripts/lib/update-session.ts +298 -0
- package/payload/scripts/live.ts +518 -0
- package/payload/scripts/navigate.ts +627 -0
- package/payload/scripts/perf.ts +238 -0
- package/payload/scripts/registry.ts +56 -14
- package/payload/scripts/route-policy.ts +62 -0
- package/payload/scripts/sizes.ts +130 -0
- package/payload/scripts/tokens.ts +5 -5
- package/payload/scripts/verify.baseline.json +27 -0
- package/payload/scripts/verify.config.ts +61 -2
- package/payload/scripts/verify.ts +388 -134
- package/payload/scripts/vitals.ts +4 -3
- package/payload/skills/zz-meridian/SKILL.md +102 -26
- package/payload/skills/zz-meridian/references/agents.md +72 -0
- package/payload/skills/zz-meridian/references/cache.md +249 -0
- package/payload/skills/zz-meridian/references/customize.md +53 -34
- package/payload/skills/zz-meridian/references/existing-project.md +39 -27
- package/payload/skills/zz-meridian/references/live.md +497 -0
- package/payload/skills/zz-meridian/references/ownership.md +19 -0
- package/payload/skills/zz-meridian/references/update.md +234 -0
- package/payload/skills/zz-meridian/references/validation.md +33 -7
- package/payload/skills/zz-meridian/references/voice.md +35 -0
- package/payload/src/app.config.ts +3 -0
- package/payload/src/components/base/app-mark/README.md +7 -4
- package/payload/src/components/base/app-mark/index.tsx +16 -4
- package/payload/src/components/base/app-mark/preview.tsx +5 -1
- package/payload/src/components/base/motion/preview.tsx +4 -1
- package/payload/src/components/base/providers.tsx +21 -8
- package/payload/src/components/base/shell/README.md +2 -2
- package/payload/src/components/base/shell/index.tsx +50 -17
- package/payload/src/components/base/surface/index.tsx +1 -1
- package/payload/src/components/charts/sparkline/index.tsx +3 -0
- package/payload/src/components/charts/uptime-bars/README.md +16 -15
- package/payload/src/components/charts/uptime-bars/index.tsx +35 -27
- package/payload/src/components/charts/uptime-bars/preview.tsx +12 -2
- package/payload/src/components/patterns/appearance-menu/index.tsx +1 -1
- package/payload/src/components/patterns/ask-about/index.tsx +1 -1
- package/payload/src/components/patterns/assistant/README.md +10 -3
- package/payload/src/components/patterns/assistant/index.tsx +0 -17
- package/payload/src/components/patterns/assistant/launcher.tsx +28 -0
- package/payload/src/components/patterns/assistant/preview.tsx +2 -1
- package/payload/src/components/patterns/data-table/README.md +6 -5
- package/payload/src/components/patterns/data-table/index.tsx +118 -85
- package/payload/src/components/patterns/export-button/README.md +8 -4
- package/payload/src/components/patterns/export-button/index.tsx +17 -3
- package/payload/src/components/patterns/export-button/preview.tsx +2 -1
- package/payload/src/components/patterns/featured-metric/README.md +1 -1
- package/payload/src/components/patterns/featured-metric/index.tsx +8 -15
- package/payload/src/components/patterns/filter-bar/index.tsx +1 -1
- package/payload/src/components/patterns/freshness/README.md +2 -2
- package/payload/src/components/patterns/freshness/index.tsx +4 -3
- package/payload/src/components/patterns/metric-tile/index.tsx +2 -9
- package/payload/src/components/patterns/rail/README.md +1 -1
- package/payload/src/components/patterns/rail/index.tsx +12 -6
- package/payload/src/components/patterns/shell-tools/README.md +2 -2
- package/payload/src/components/patterns/shell-tools/index.tsx +3 -3
- package/payload/src/components/patterns/status-list/index.tsx +14 -7
- package/payload/src/components/patterns/status-list/summarise.ts +13 -1
- package/payload/src/components/ui/banner/README.md +1 -1
- package/payload/src/components/ui/button/README.md +5 -5
- package/payload/src/components/ui/button/preview.tsx +1 -1
- package/payload/src/components/ui/card/README.md +2 -2
- package/payload/src/components/ui/card/index.tsx +1 -1
- package/payload/src/components/ui/dialog/README.md +1 -1
- package/payload/src/components/ui/icon-button/README.md +3 -3
- package/payload/src/components/ui/input/README.md +4 -4
- package/payload/src/components/ui/input/preview.tsx +1 -1
- package/payload/src/components/ui/menu/README.md +2 -2
- package/payload/src/components/ui/pagination/README.md +1 -1
- package/payload/src/components/ui/popover/README.md +2 -2
- package/payload/src/components/ui/popover/index.tsx +0 -1
- package/payload/src/components/ui/segmented/README.md +2 -2
- package/payload/src/components/ui/select/README.md +1 -1
- package/payload/src/components/ui/sheet/README.md +1 -1
- package/payload/src/components/ui/sheet/index.tsx +1 -1
- package/payload/src/components/ui/skeleton/README.md +1 -1
- package/payload/src/components/ui/table/README.md +2 -2
- package/payload/src/components/ui/table/index.tsx +2 -2
- package/payload/src/components/ui/textarea/README.md +1 -1
- package/payload/src/components/ui/toast/README.md +1 -1
- package/payload/src/components/ui/tooltip/README.md +1 -1
- package/payload/src/data/README.md +9 -1
- package/payload/src/data/access.ts +73 -0
- package/payload/src/data/collections.ts +18 -2
- package/payload/src/data/live-actions.ts +22 -0
- package/payload/src/data/live-stream.ts +104 -0
- package/payload/src/data/read.ts +30 -0
- package/payload/src/data/requests.ts +102 -0
- package/payload/src/data/sample.ts +12 -0
- package/payload/src/lib/assistant/brief.ts +15 -0
- package/payload/src/lib/assistant/prompt.ts +64 -3
- package/payload/src/lib/assistant/respond.ts +7 -6
- package/payload/src/lib/assistant/tools.ts +23 -5
- package/payload/src/lib/collection.ts +81 -10
- package/payload/src/lib/csv.ts +14 -3
- package/payload/src/lib/format-date.ts +1 -1
- package/payload/src/lib/format.ts +22 -4
- package/payload/src/lib/host.ts +10 -0
- package/payload/src/lib/live.ts +307 -0
- package/payload/src/lib/logo.ts +13 -0
- package/payload/src/lib/preferences.ts +10 -5
- package/payload/src/lib/safe-markdown.ts +9 -1
- package/payload/src/styles/motion.css +0 -5
- package/payload/src/styles/tokens.css +0 -1
- package/payload/src/system/content.ts +18 -9
- package/payload/src/system/fixtures/sample-records.ts +1 -1
- package/payload/src/system/fixtures/sample.ts +1 -2
- package/payload/src/system/markdown.tsx +1 -1
- package/payload/src/system/page-stage.tsx +1 -1
- package/payload/src/system/preview-loaders.ts +80 -0
- package/payload/src/system/registry.ts +1 -1
- package/payload/src/system/token-view.tsx +1 -1
- package/payload/src/system/tokens-data.ts +1 -1
- package/payload/src/views/analytics.tsx +10 -2
- package/payload/src/views/console-live.tsx +45 -0
- package/payload/src/views/customers.tsx +49 -47
- package/payload/src/views/health.tsx +17 -4
- package/payload/src/views/key-scopes.ts +4 -0
- package/payload/src/views/keys.tsx +38 -21
- package/payload/src/views/members.tsx +50 -15
- package/payload/src/views/overview.tsx +11 -1
- package/payload/src/views/request.tsx +3 -4
- package/payload/src/views/requests.tsx +73 -94
- package/payload/src/views/sample-footer.tsx +1 -1
- package/payload/src/views/settings.tsx +26 -5
- package/payload/tests/agents.test.tsx +29 -0
- package/payload/tests/assistant-brief.test.ts +50 -0
- package/payload/tests/assistant-errors.test.ts +4 -1
- package/payload/tests/assistant-guard.test.ts +37 -0
- package/payload/tests/assistant-panel.test.tsx +14 -11
- package/payload/tests/assistant-respond.test.ts +5 -2
- package/payload/tests/assistant-safety.test.ts +10 -4
- package/payload/tests/assistant-settings.test.tsx +15 -12
- package/payload/tests/assistant-tools.test.ts +15 -13
- package/payload/tests/brand-config.test.tsx +47 -0
- package/payload/tests/brand-literal.test.ts +30 -0
- package/payload/tests/chrome-cleanup.test.ts +26 -0
- package/payload/tests/clock.test.tsx +30 -0
- package/payload/tests/collection.test.ts +13 -0
- package/payload/tests/console-live.test.tsx +33 -0
- package/payload/tests/context-check.test.ts +56 -0
- package/payload/tests/csv-safety.test.ts +19 -0
- package/payload/tests/customers-masthead.test.tsx +20 -0
- package/payload/tests/data-access.test.ts +93 -0
- package/payload/tests/data-read.test.ts +106 -0
- package/payload/tests/data-table.test.tsx +48 -0
- package/payload/tests/data-write.test.ts +56 -0
- package/payload/tests/embed-requests-provenance.test.tsx +39 -0
- package/payload/tests/empty-views.test.tsx +29 -0
- package/payload/tests/format.test.ts +19 -1
- package/payload/tests/keys-actions.test.ts +20 -0
- package/payload/tests/keys-scopes-view.test.tsx +19 -0
- package/payload/tests/live-actions.test.ts +27 -0
- package/payload/tests/live-client.test.tsx +228 -0
- package/payload/tests/live-route.test.ts +125 -0
- package/payload/tests/loading-routes.test.tsx +40 -0
- package/payload/tests/members-optimistic.test.tsx +122 -0
- package/payload/tests/page-fallbacks.test.tsx +42 -0
- package/payload/tests/preferences.test.tsx +41 -0
- package/payload/tests/prose.test.tsx +11 -0
- package/payload/tests/requests-export.test.ts +51 -0
- package/payload/tests/requests-query.test.ts +39 -0
- package/payload/tests/route-policy.test.ts +19 -0
- package/payload/tests/settings-disconnect-undo.test.tsx +25 -0
- package/payload/tests/shell-promise.test.tsx +50 -0
- package/payload/tests/sparkline.test.tsx +30 -0
- package/payload/tests/update-session.test.ts +127 -0
- package/payload/tests/uptime-svg.test.tsx +60 -0
- package/payload/tests/verify-coverage.test.ts +41 -0
- package/payload/tests/verify-next-config.test.ts +32 -0
- package/payload/tests/verify-sizes.test.ts +51 -0
- package/payload/tests/verify-suite-outcome.test.ts +26 -0
- package/payload/tests/verify-timing.test.ts +28 -0
- package/payload/tokens/core.tokens.json +0 -8
- package/payload/app/(dashboard)/loading.tsx +0 -40
- package/payload/app/(dashboard)/page.tsx +0 -33
package/README.md
CHANGED
|
@@ -16,15 +16,18 @@ Give your coding agent (Codex, Claude Code, or any agent that can run a shell) t
|
|
|
16
16
|
```sh
|
|
17
17
|
npx zz-meridian@latest adopt [brand flags] # bring Meridian into this Next.js App Router project
|
|
18
18
|
npx zz-meridian@latest create <dir> [brand flags] # start a new dashboard
|
|
19
|
+
npx zz-meridian@latest update [--dry-run] [--verbose] # update this project (the dry-run writes nothing)
|
|
20
|
+
npx zz-meridian@<version> brand [brand flags] # rebrand this project, with the version in .meridian/manifest.json
|
|
19
21
|
npx zz-meridian@latest skill [--global] # install only the agent skill
|
|
20
22
|
```
|
|
21
23
|
|
|
22
|
-
Brand flags: `--name "Acme Ops"`, `--hex '#2E6BE4'` (or `--accent indigo|cobalt|jade|graphite`), `--
|
|
23
|
-
`--timezone`, `--currency`, `--user`, `--role`.
|
|
24
|
+
Brand flags: `--name "Acme Ops"`, `--hex '#2E6BE4'` (or `--accent indigo|cobalt|jade|graphite`), `--theme dark|light`,
|
|
25
|
+
`--workspace`, `--timezone`, `--currency`, `--user`, `--role`.
|
|
24
26
|
|
|
25
27
|
**adopt** copies Meridian's tokens, styles, components, gates and scripts into `src/`, `tokens/` and `scripts/`;
|
|
26
28
|
merges the dependencies and scripts it needs into your `package.json`; replaces your global stylesheet (yours is kept
|
|
27
|
-
beside it as `*.before.css`); brands it;
|
|
29
|
+
beside it as `*.before.css`); brands it; adds Meridian's managed block to your `AGENTS.md` between two markers, keeping your own text there byte
|
|
30
|
+
for byte; writes an empty `docs/brief.md` (your product, users, data, decisions and glossary) if you have none; installs the skill into
|
|
28
31
|
`.agents/skills/` (Codex) and `.claude/skills/` (Claude Code); records every copied file in `.meridian/manifest.json`;
|
|
29
32
|
installs and type checks. Your routes, your data layer and your own components are not touched. Meridian's files
|
|
30
33
|
import each other by relative path, so a `components/ui/button` of your own is never confused with Meridian's; your
|
|
@@ -36,7 +39,31 @@ the project is not Next.js with the App Router, or when a file it would copy alr
|
|
|
36
39
|
**create** copies the template into a new folder, branded as your product, with the Design Atlas and the design
|
|
37
40
|
system's own documents left out.
|
|
38
41
|
|
|
39
|
-
|
|
42
|
+
**update** moves a project adopted or created with 0.3.0 or later to this release. `update --dry-run` shows the plan and
|
|
43
|
+
writes nothing; run it first.
|
|
44
|
+
- It fetches the release the project was copied from, checks it against the registry's integrity record, and replays
|
|
45
|
+
that release and this one in a scratch folder. If a file the manifest recorded does not match the replay, it stops.
|
|
46
|
+
- It replaces every Meridian file you have not touched, adds the new ones and removes the retired ones. A file you edited,
|
|
47
|
+
deleted or kept (`.meridian/keep.json`) is never overwritten: an edited or deleted one is staged as base/ours/new copies
|
|
48
|
+
for you to merge.
|
|
49
|
+
- It adds the dependencies and scripts this release needs to `package.json` and updates Meridian's managed block in
|
|
50
|
+
`AGENTS.md`; a version or script you chose yourself becomes a migration to resolve, never a silent change.
|
|
51
|
+
- It reports a migration for each interface the release changed where one of your own files still has the old shape;
|
|
52
|
+
moving to 0.5.0 can report ten, such as `cache-components-config`. Resolve them as the skill's
|
|
53
|
+
`references/update.md` says; `docs/distribution.md` lists them.
|
|
54
|
+
- Everything it did and everything left to do is in `.meridian/update/<version>/MERGE.md`. Resolve the items, then run
|
|
55
|
+
the pinned `npx zz-meridian@<version> update --finalize`; `--resume` continues an interrupted run and `--abort` restores
|
|
56
|
+
what it changed. Only finalize records the new version. `--finalize --verify` validates with the project's default
|
|
57
|
+
`verify` instead of the gate and the build, and reports its coverage line.
|
|
58
|
+
- It refuses, writing nothing, on a dirty git tree (unless `--allow-dirty`) or while another update is open. `--verbose`
|
|
59
|
+
lists every file.
|
|
60
|
+
|
|
61
|
+
**brand** changes the brand of a project built on Meridian with no hand edits: it rebuilds the brand outputs (tokens and
|
|
62
|
+
styles) and `src/app.config.ts` for the new flags and records them in the manifest together, or changes nothing. It
|
|
63
|
+
refuses when one of those outputs was edited, while an update is open, or on a dirty tree (unless `--allow-dirty`). Run
|
|
64
|
+
it with the version the manifest records. The project's own `pnpm brand` still works, but its changes count as your edits.
|
|
65
|
+
|
|
66
|
+
Requires Node 22.18 or newer. The default `pnpm verify` runs without Google Chrome and reports the browser checks as not run; `--full` and `--perf` need it.
|
|
40
67
|
|
|
41
68
|
## What this package does not do
|
|
42
69
|
|
package/dist/adopt.js
CHANGED
|
@@ -8,25 +8,11 @@
|
|
|
8
8
|
*/
|
|
9
9
|
import fs from 'node:fs';
|
|
10
10
|
import path from 'node:path';
|
|
11
|
-
import { PAYLOAD, VERSION, brandArgs, inside, installSkill, packageManager, payloadFiles, readJsonc, readPayload, relativeImports, run, runTool, sha256, writeIn, writeManifest, } from './files.js';
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
/** What adopt copies from the template, as payload paths. The fixture build in CI is what keeps this list complete. */
|
|
15
|
-
export function adoptSet() {
|
|
16
|
-
const own = (f) => !/(^|\/)(README\.md|preview\.tsx)$/.test(f);
|
|
17
|
-
return [
|
|
18
|
-
...payloadFiles('tokens'),
|
|
19
|
-
...payloadFiles('src/styles'),
|
|
20
|
-
...payloadFiles('src/components').filter(own),
|
|
21
|
-
...payloadFiles('scripts').filter((f) => f !== 'scripts/verify.config.ts'),
|
|
22
|
-
...LIB,
|
|
23
|
-
'src/lib/assistant/prompt.ts',
|
|
24
|
-
'src/views/console-chrome.tsx',
|
|
25
|
-
'tests/setup.ts',
|
|
26
|
-
];
|
|
27
|
-
}
|
|
11
|
+
import { PAYLOAD, VERSION, brandArgs, effectiveBrand, inside, installSkill, packageManager, payloadFiles, readJsonc, readPayload, relativeImports, run, runTool, sha256, writeIn, writeManifest, } from './files.js';
|
|
12
|
+
import { BRIEF_TEMPLATE, managedBlock, upsertManagedBlock } from './context.js';
|
|
13
|
+
import { FIXED, adoptSetOf, managedPaths } from './ownership.js';
|
|
28
14
|
/** The template's dependencies a dashboard built from these files needs. */
|
|
29
|
-
const DEPS = ['next', 'react', 'react-dom', 'radix-ui', 'lucide-react', 'clsx', 'tailwind-merge', 'react-markdown', 'remark-gfm', 'ai', '@ai-sdk/react'];
|
|
15
|
+
const DEPS = ['next', 'react', 'react-dom', 'radix-ui', 'lucide-react', 'clsx', 'tailwind-merge', 'react-markdown', 'remark-gfm', 'ai', '@ai-sdk/react', 'zod'];
|
|
30
16
|
const DEV_DEPS = ['typescript', '@types/node', '@types/react', '@types/react-dom', 'tailwindcss', '@tailwindcss/postcss', 'eslint', 'eslint-config-next', 'vitest', '@vitejs/plugin-react', 'jsdom', '@testing-library/react', '@testing-library/jest-dom'];
|
|
31
17
|
const SCRIPTS = ['typecheck', 'test', 'tokens', 'check', 'contrast', 'gate', 'audit', 'verify', 'brand', 'shot', 'interactions', 'keyboard', 'vitals'];
|
|
32
18
|
const major = (spec) => Number(/(\d+)/.exec(spec)?.[1] ?? NaN);
|
|
@@ -45,6 +31,22 @@ function exactImports(rel, source, known) {
|
|
|
45
31
|
};
|
|
46
32
|
return relativeImports(rel, source).replace(/(\bfrom\s*|\bimport\s*\(\s*|\bimport\s+)(['"])([\w@./()[\]-]+)\2/g, (_m, lead, q, spec) => `${lead}${q}${pin(spec)}${q}`);
|
|
47
33
|
}
|
|
34
|
+
/** Every file under one folder of the project, as posix paths; none when the folder is absent. */
|
|
35
|
+
function listFiles(root, dir) {
|
|
36
|
+
const out = [];
|
|
37
|
+
const walk = (rel) => {
|
|
38
|
+
if (!fs.existsSync(path.join(root, rel)))
|
|
39
|
+
return;
|
|
40
|
+
for (const e of fs.readdirSync(path.join(root, rel), { withFileTypes: true })) {
|
|
41
|
+
if (e.isDirectory())
|
|
42
|
+
walk(`${rel}/${e.name}`);
|
|
43
|
+
else if (e.isFile())
|
|
44
|
+
out.push(`${rel}/${e.name}`);
|
|
45
|
+
}
|
|
46
|
+
};
|
|
47
|
+
walk(dir);
|
|
48
|
+
return out;
|
|
49
|
+
}
|
|
48
50
|
function appDir(root) {
|
|
49
51
|
for (const d of ['app', 'src/app'])
|
|
50
52
|
if (['tsx', 'jsx', 'ts', 'js'].some((x) => fs.existsSync(path.join(root, d, `layout.${x}`))))
|
|
@@ -128,7 +130,18 @@ https://github.com/zhixuan312/zz-meridian/blob/master/skills/zz-meridian/referen
|
|
|
128
130
|
if (git.stdout.trim())
|
|
129
131
|
return fail('the git tree has uncommitted changes. Commit or stash them first, so adopt\'s change is one reviewable diff (or pass --allow-dirty).');
|
|
130
132
|
}
|
|
131
|
-
|
|
133
|
+
let brand;
|
|
134
|
+
try {
|
|
135
|
+
brand = effectiveBrand(o.brand);
|
|
136
|
+
}
|
|
137
|
+
catch (e) {
|
|
138
|
+
return fail(e.message);
|
|
139
|
+
}
|
|
140
|
+
const all = payloadFiles();
|
|
141
|
+
const missing = FIXED.filter((f) => !all.includes(f));
|
|
142
|
+
if (missing.length)
|
|
143
|
+
return fail(`this package's template is missing files adopt needs, so nothing was written:\n${missing.map((m) => ` ${m}`).join('\n')}\nReinstall zz-meridian, or report this if it persists.`);
|
|
144
|
+
const set = adoptSetOf(all);
|
|
132
145
|
const known = new Set(set);
|
|
133
146
|
const writes = new Map();
|
|
134
147
|
const owned = [];
|
|
@@ -257,18 +270,39 @@ Rename or move them, then run adopt again.`);
|
|
|
257
270
|
}
|
|
258
271
|
for (const [rel, content] of writes)
|
|
259
272
|
writeIn(root, rel, content);
|
|
260
|
-
const name =
|
|
261
|
-
|
|
273
|
+
const name = brand.name ?? String(pkg.name ?? path.basename(root)).replace(/^@[^/]+\//, '').replace(/[-_]+/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase());
|
|
274
|
+
brand = effectiveBrand({ ...brand, name });
|
|
275
|
+
// The brand outputs this run creates under tokens/ and src/styles/ (absent before it) are Meridian's, so the manifest records them.
|
|
276
|
+
const brandDirs = () => ['tokens', 'src/styles'].flatMap((d) => listFiles(root, d));
|
|
277
|
+
const existed = new Set(brandDirs());
|
|
262
278
|
const b = run(process.execPath, ['scripts/brand.ts', ...brandArgs(brand), '--existing'], root);
|
|
263
279
|
if (b.status !== 0)
|
|
264
280
|
return fail('scripts/brand.ts failed (above); the files are copied, so fix the cause and run it again with the same flags');
|
|
281
|
+
// The project's own AGENTS.md keeps every byte and gains the managed block; the brief is the team's, written only when absent.
|
|
282
|
+
// Neither is a Meridian file, so neither is recorded in the manifest.
|
|
283
|
+
const pm = packageManager(root);
|
|
284
|
+
const agentsAbs = path.join(root, 'AGENTS.md');
|
|
285
|
+
try {
|
|
286
|
+
const text = fs.existsSync(agentsAbs) ? fs.readFileSync(agentsAbs, 'utf8') : '';
|
|
287
|
+
writeIn(root, 'AGENTS.md', upsertManagedBlock(text, managedBlock(VERSION, pm)));
|
|
288
|
+
}
|
|
289
|
+
catch (e) {
|
|
290
|
+
return fail(`AGENTS.md: ${e.message} The files are copied; fix AGENTS.md by hand, then add the managed block.`);
|
|
291
|
+
}
|
|
292
|
+
const briefWritten = !fs.existsSync(path.join(root, 'docs/brief.md'));
|
|
293
|
+
if (briefWritten)
|
|
294
|
+
writeIn(root, 'docs/brief.md', BRIEF_TEMPLATE);
|
|
295
|
+
const generated = brandDirs().filter((f) => !existed.has(f));
|
|
296
|
+
if (owned.includes('scripts/package.json'))
|
|
297
|
+
generated.push('scripts/package.json');
|
|
298
|
+
const managed = managedPaths(all, 'adopt', generated);
|
|
265
299
|
const files = {};
|
|
266
|
-
for (const rel of owned)
|
|
267
|
-
|
|
300
|
+
for (const rel of [...owned, ...generated])
|
|
301
|
+
if (managed.has(rel))
|
|
302
|
+
files[rel] = sha256(fs.readFileSync(inside(root, rel)));
|
|
268
303
|
installSkill(root, files);
|
|
269
304
|
writeManifest(root, { version: VERSION, route: 'adopt', brand, files });
|
|
270
305
|
// ── Install and prove it compiles ─────────────────────────────────────────────────────────────────────
|
|
271
|
-
const pm = packageManager(root);
|
|
272
306
|
let typed = 'not run (--no-install)';
|
|
273
307
|
if (o.install) {
|
|
274
308
|
say(`\nInstalling with ${pm}…`);
|
|
@@ -283,6 +317,7 @@ Rename or move them, then run adopt again.`);
|
|
|
283
317
|
Meridian ${VERSION} is in ${root}.
|
|
284
318
|
copied: ${owned.length} files (recorded in .meridian/manifest.json)
|
|
285
319
|
skill: .agents/skills/zz-meridian (Codex) and .claude/skills/zz-meridian (Claude Code)
|
|
320
|
+
agent context: AGENTS.md has the managed block; docs/brief.md ${briefWritten ? 'is the empty brief to fill in' : 'was kept as it is'}
|
|
286
321
|
types: ${typed}${notes.length ? `\n notes:\n${notes.map((n) => ` - ${n}`).join('\n')}` : ''}
|
|
287
322
|
|
|
288
323
|
Next: follow .agents/skills/zz-meridian/references/existing-project.md, Route A, from step 2 (step 1 was this). The
|
package/dist/cli.js
CHANGED
|
@@ -9,6 +9,8 @@ import { parseArgs } from 'node:util';
|
|
|
9
9
|
import { adopt } from './adopt.js';
|
|
10
10
|
import { create } from './create.js';
|
|
11
11
|
import { BRAND_FLAGS, VERSION, installSkill } from './files.js';
|
|
12
|
+
import { brandCommand } from './rebrand.js';
|
|
13
|
+
import { update } from './update.js';
|
|
12
14
|
const HELP = `zz-meridian ${VERSION}
|
|
13
15
|
|
|
14
16
|
npx zz-meridian@latest adopt [brand flags] [--allow-dirty] [--no-install]
|
|
@@ -17,6 +19,19 @@ const HELP = `zz-meridian ${VERSION}
|
|
|
17
19
|
npx zz-meridian@latest create <dir> [brand flags] [--no-install]
|
|
18
20
|
Start a new dashboard on Meridian in <dir>.
|
|
19
21
|
|
|
22
|
+
npx zz-meridian@latest update --dry-run [--verbose]
|
|
23
|
+
Show what updating this project to the running version would change, and what needs your decision. Writes nothing.
|
|
24
|
+
|
|
25
|
+
npx zz-meridian@latest update [--allow-dirty] [--no-install] [--verbose]
|
|
26
|
+
Apply the safe changes, stage everything you changed with base/ours/new copies, and write .meridian/update/<version>/MERGE.md.
|
|
27
|
+
|
|
28
|
+
npx zz-meridian@<version> update --resume [--no-install] | --finalize [--verify] | --abort
|
|
29
|
+
Continue, complete or roll back the update in progress. Use the version named in MERGE.md.
|
|
30
|
+
|
|
31
|
+
npx zz-meridian@<installed version> brand [brand flags] [--allow-dirty]
|
|
32
|
+
Change this project's brand with no hand edits: the brand outputs, src/app.config.ts and the manifest move together, or nothing changes.
|
|
33
|
+
Use the version in .meridian/manifest.json; a project on an older release runs update first.
|
|
34
|
+
|
|
20
35
|
npx zz-meridian@latest skill [--global]
|
|
21
36
|
Install only the agent skill: into this project, or for every project (~/.agents/skills, ~/.claude/skills).
|
|
22
37
|
|
|
@@ -30,6 +45,12 @@ const { values, positionals } = parseArgs({
|
|
|
30
45
|
...Object.fromEntries(BRAND_FLAGS.map((f) => [f, { type: 'string' }])),
|
|
31
46
|
'allow-dirty': { type: 'boolean' },
|
|
32
47
|
'no-install': { type: 'boolean' },
|
|
48
|
+
'dry-run': { type: 'boolean' },
|
|
49
|
+
resume: { type: 'boolean' },
|
|
50
|
+
finalize: { type: 'boolean' },
|
|
51
|
+
abort: { type: 'boolean' },
|
|
52
|
+
verify: { type: 'boolean' },
|
|
53
|
+
verbose: { type: 'boolean' },
|
|
33
54
|
global: { type: 'boolean' },
|
|
34
55
|
help: { type: 'boolean', short: 'h' },
|
|
35
56
|
version: { type: 'boolean', short: 'v' },
|
|
@@ -63,6 +84,21 @@ function main() {
|
|
|
63
84
|
}
|
|
64
85
|
return create({ dir: arg, brand, install });
|
|
65
86
|
}
|
|
87
|
+
if (command === 'update') {
|
|
88
|
+
const modes = ['dry-run', 'resume', 'finalize', 'abort'].filter((m) => values[m]);
|
|
89
|
+
if (modes.length > 1) {
|
|
90
|
+
console.error(`zz-meridian update: ${modes.map((m) => `--${m}`).join(' and ')} cannot be combined`);
|
|
91
|
+
return 1;
|
|
92
|
+
}
|
|
93
|
+
if (values.verify && !values.finalize) {
|
|
94
|
+
console.error('zz-meridian update: --verify belongs to --finalize');
|
|
95
|
+
return 1;
|
|
96
|
+
}
|
|
97
|
+
const mode = modes[0] ?? 'update';
|
|
98
|
+
return update({ root: process.cwd(), mode, flags: { verbose: Boolean(values.verbose), allowDirty: Boolean(values['allow-dirty']), install, verify: Boolean(values.verify) } });
|
|
99
|
+
}
|
|
100
|
+
if (command === 'brand')
|
|
101
|
+
return brandCommand({ root: process.cwd(), flags: brand, allowDirty: Boolean(values['allow-dirty']) });
|
|
66
102
|
if (command === 'skill') {
|
|
67
103
|
const roots = values.global ? [os.homedir()] : [process.cwd()];
|
|
68
104
|
for (const r of roots)
|
package/dist/context.js
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The agent-facing context a project carries: the frozen managed block in `AGENTS.md` and the brief template.
|
|
3
|
+
* Pure text in, text out; the caller does the reading and writing.
|
|
4
|
+
*/
|
|
5
|
+
export const BEGIN = '<!-- BEGIN:zz-meridian-agent-rules -->';
|
|
6
|
+
export const END = '<!-- END:zz-meridian-agent-rules -->';
|
|
7
|
+
const BLOCK = `${BEGIN}
|
|
8
|
+
# Built on ZZ Meridian <version>
|
|
9
|
+
|
|
10
|
+
Follow the zz-meridian skill in \`.agents/skills/zz-meridian/SKILL.md\`.
|
|
11
|
+
Read \`optional:docs/brief.md\` for this team's product, users, data and decisions.
|
|
12
|
+
When it is absent, use the skill's brief template with the person's answers; do not invent product facts.
|
|
13
|
+
|
|
14
|
+
- What is yours: product pages, data, other team files, \`src/app.config.ts\` and \`scripts/verify.config.ts\`.
|
|
15
|
+
Managed paths are recorded in \`.meridian/manifest.json\`; declared kept divergences are in
|
|
16
|
+
\`optional:.meridian/keep.json\`. Explicit shared-file operations are described by the skill.
|
|
17
|
+
- Extend rather than edit: follow \`.agents/skills/zz-meridian/references/customize.md\`.
|
|
18
|
+
- Update: follow \`.agents/skills/zz-meridian/references/update.md\`. Installed files do not mean a completed
|
|
19
|
+
upgrade: resolve the report and run the pinned finalization command.
|
|
20
|
+
- Before finishing: safely configured \`pnpm verify\` for the default gate/build/smoke path; report any partial coverage.
|
|
21
|
+
Use \`pnpm gate\` alone for static feedback. Do not automatically chain it before verify, which already runs it.
|
|
22
|
+
Use \`pnpm verify --full\` or \`pnpm verify --perf\` when deep checks are requested; release CI runs both.
|
|
23
|
+
- Agents in the product read through authorized collections and write only through an approved Proposal.
|
|
24
|
+
${END}`;
|
|
25
|
+
/** The brief a team keeps at `docs/brief.md`; its guidance lines equal `BRIEF_GUIDANCE` in the assistant prompt. */
|
|
26
|
+
export const BRIEF_TEMPLATE = `# Product name
|
|
27
|
+
|
|
28
|
+
## Product
|
|
29
|
+
What this dashboard is for, in two or three sentences: who opens it, what decision it helps them make.
|
|
30
|
+
|
|
31
|
+
## Users
|
|
32
|
+
Who uses it and how often; what they know already; what they must never be shown.
|
|
33
|
+
|
|
34
|
+
## Data
|
|
35
|
+
Where the numbers come from (systems, tables, APIs), how fresh they are, and what now means for this product.
|
|
36
|
+
|
|
37
|
+
## Decisions
|
|
38
|
+
Brand, layout and behaviour choices already made, one line each with its reason, so no session re-decides them.
|
|
39
|
+
|
|
40
|
+
## Glossary
|
|
41
|
+
The team's own words for things, one per line: term and what it means here.
|
|
42
|
+
`;
|
|
43
|
+
/** One `pnpm <script> [flags]` command, spelled for the package manager; npm needs `--` before flags. */
|
|
44
|
+
function command(pm, script, flags) {
|
|
45
|
+
if (pm === 'pnpm')
|
|
46
|
+
return `pnpm ${script}${flags}`;
|
|
47
|
+
if (pm === 'npm')
|
|
48
|
+
return `npm run ${script}${flags ? ` --${flags}` : ''}`;
|
|
49
|
+
return `${pm === 'bun' ? 'bun run' : 'yarn'} ${script}${flags}`;
|
|
50
|
+
}
|
|
51
|
+
/** The frozen managed block for a version, with every script command in the project's package manager. */
|
|
52
|
+
export function managedBlock(version, pm) {
|
|
53
|
+
return BLOCK.replace('<version>', () => version).replace(/`pnpm ([a-z]+)((?: [^`]+)?)`/g, (_m, script, flags) => `\`${command(pm, script, flags)}\``);
|
|
54
|
+
}
|
|
55
|
+
const count = (text, needle) => text.split(needle).length - 1;
|
|
56
|
+
/** Put the block into an `AGENTS.md` text: replace the marked span, else an exact legacy section, else append. Refuses anything ambiguous. */
|
|
57
|
+
export function upsertManagedBlock(text, block, legacy) {
|
|
58
|
+
const begins = count(text, BEGIN);
|
|
59
|
+
const ends = count(text, END);
|
|
60
|
+
const legacies = legacy ? count(text, legacy) : 0;
|
|
61
|
+
if (begins > 1 || ends > 1)
|
|
62
|
+
throw new Error('AGENTS.md has a duplicate managed block marker; remove the extra one and run again.');
|
|
63
|
+
if (legacies > 1)
|
|
64
|
+
throw new Error('AGENTS.md has a duplicate legacy section; remove the extra copy and run again.');
|
|
65
|
+
const begin = text.indexOf(BEGIN);
|
|
66
|
+
const end = text.indexOf(END);
|
|
67
|
+
if (begins !== ends || end < begin)
|
|
68
|
+
throw new Error('AGENTS.md has an unterminated managed block; each marker needs its partner, BEGIN first.');
|
|
69
|
+
if (begins === 1 && legacies === 1)
|
|
70
|
+
throw new Error('AGENTS.md has both a managed block and a legacy section; remove one and run again.');
|
|
71
|
+
if (begins === 1)
|
|
72
|
+
return text.slice(0, begin) + block + text.slice(end + END.length);
|
|
73
|
+
if (legacy && legacies === 1) {
|
|
74
|
+
const at = text.indexOf(legacy);
|
|
75
|
+
return text.slice(0, at) + block + text.slice(at + legacy.length);
|
|
76
|
+
}
|
|
77
|
+
if (legacy && text.includes(legacy.split('\n')[0]))
|
|
78
|
+
throw new Error('AGENTS.md has an edited legacy section; replace it by hand with the managed block.');
|
|
79
|
+
if (text === '')
|
|
80
|
+
return `${block}\n`;
|
|
81
|
+
return `${text}${text.endsWith('\n') ? '' : '\n'}\n${block}\n`;
|
|
82
|
+
}
|
package/dist/create.js
CHANGED
|
@@ -5,18 +5,27 @@
|
|
|
5
5
|
*/
|
|
6
6
|
import fs from 'node:fs';
|
|
7
7
|
import path from 'node:path';
|
|
8
|
-
import {
|
|
8
|
+
import { managedPaths } from './ownership.js';
|
|
9
|
+
import { BRIEF_TEMPLATE, managedBlock, upsertManagedBlock } from './context.js';
|
|
10
|
+
import { PAYLOAD, VERSION, brandArgs, effectiveBrand, hasCommand, inside, installSkill, payloadFiles, run, sha256, writeIn, writeManifest } from './files.js';
|
|
9
11
|
export function create(o) {
|
|
10
12
|
const root = path.resolve(o.dir);
|
|
11
13
|
if (fs.existsSync(root) && fs.readdirSync(root).length)
|
|
12
14
|
return fail(`${root} exists and is not empty: name a new folder`);
|
|
15
|
+
let brand;
|
|
16
|
+
try {
|
|
17
|
+
brand = effectiveBrand(o.brand);
|
|
18
|
+
}
|
|
19
|
+
catch (e) {
|
|
20
|
+
return fail(e.message);
|
|
21
|
+
}
|
|
13
22
|
for (const rel of payloadFiles()) {
|
|
14
23
|
if (rel.startsWith('skills/'))
|
|
15
24
|
continue;
|
|
16
25
|
writeIn(root, rel, fs.readFileSync(path.join(PAYLOAD, rel)));
|
|
17
26
|
}
|
|
18
|
-
const name =
|
|
19
|
-
|
|
27
|
+
const name = brand.name ?? path.basename(root).replace(/[-_]+/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase());
|
|
28
|
+
brand = effectiveBrand({ ...brand, name });
|
|
20
29
|
if (run(process.execPath, ['scripts/brand.ts', ...brandArgs(brand), '--product'], root).status !== 0) {
|
|
21
30
|
return fail('scripts/brand.ts failed (above); the template is copied, so fix the cause and run it again with the same flags');
|
|
22
31
|
}
|
|
@@ -29,6 +38,15 @@ export function create(o) {
|
|
|
29
38
|
fs.rmSync(path.join(root, f), { force: true });
|
|
30
39
|
console.log('pnpm is not available: installing with npm from the version ranges, without a lockfile.');
|
|
31
40
|
}
|
|
41
|
+
// The managed block goes in only now that the installer is known, so its commands match; the template's own lines
|
|
42
|
+
// above `# Working in Meridian` stay. The brief is the template. The manifest walk below records both.
|
|
43
|
+
try {
|
|
44
|
+
writeIn(root, 'AGENTS.md', upsertManagedBlock(fs.readFileSync(path.join(root, 'AGENTS.md'), 'utf8'), managedBlock(VERSION, pm)));
|
|
45
|
+
}
|
|
46
|
+
catch (e) {
|
|
47
|
+
return fail(`AGENTS.md: ${e.message}`);
|
|
48
|
+
}
|
|
49
|
+
writeIn(root, 'docs/brief.md', BRIEF_TEMPLATE);
|
|
32
50
|
const files = {};
|
|
33
51
|
const walk = (rel) => {
|
|
34
52
|
for (const e of fs.readdirSync(inside(root, rel || '.'), { withFileTypes: true })) {
|
|
@@ -42,6 +60,12 @@ export function create(o) {
|
|
|
42
60
|
}
|
|
43
61
|
};
|
|
44
62
|
walk('');
|
|
63
|
+
// The managed set over what is on disk after branding: the payload rule for the files still there, plus the brand outputs.
|
|
64
|
+
const onDisk = Object.keys(files);
|
|
65
|
+
const managed = managedPaths([...payloadFiles().filter((f) => f.startsWith('skills/') || onDisk.includes(f))], 'create', onDisk);
|
|
66
|
+
for (const rel of onDisk)
|
|
67
|
+
if (!managed.has(rel))
|
|
68
|
+
delete files[rel];
|
|
45
69
|
installSkill(root, files);
|
|
46
70
|
writeManifest(root, { version: VERSION, route: 'create', brand, files });
|
|
47
71
|
if (hasCommand('git'))
|
package/dist/files.js
CHANGED
|
@@ -140,5 +140,12 @@ export function installSkill(root, record) {
|
|
|
140
140
|
}
|
|
141
141
|
}
|
|
142
142
|
/** Brand flags passed through to scripts/brand.ts, in the order it reads them. */
|
|
143
|
-
export const BRAND_FLAGS = ['name', 'workspace', 'timezone', 'currency', 'user', 'role', 'accent', 'hex', 'hue', 'chroma'];
|
|
143
|
+
export const BRAND_FLAGS = ['name', 'workspace', 'timezone', 'currency', 'user', 'role', 'theme', 'accent', 'hex', 'hue', 'chroma'];
|
|
144
144
|
export const brandArgs = (brand) => BRAND_FLAGS.flatMap((k) => (brand[k] ? [`--${k}`, brand[k]] : []));
|
|
145
|
+
/** The brand a run effectively applied: one accent choice (hex over hue over a preset), flags in order, empty ones dropped. */
|
|
146
|
+
export function effectiveBrand(flags) {
|
|
147
|
+
if (flags.theme && flags.theme !== 'dark' && flags.theme !== 'light')
|
|
148
|
+
throw new Error(`--theme must be dark or light, got ${JSON.stringify(flags.theme)}`);
|
|
149
|
+
const drop = flags.hex ? ['hue', 'chroma', 'accent'] : flags.hue ? ['accent'] : [];
|
|
150
|
+
return Object.fromEntries(BRAND_FLAGS.flatMap((k) => (flags[k] && !drop.includes(k) ? [[k, flags[k]]] : [])));
|
|
151
|
+
}
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The typed inventory finalize validates against: every path the checks must leave alone (protected inputs), and the
|
|
3
|
+
* few outputs a gate and a build are expected to write (permitted outputs). A fingerprint is existence plus a SHA-256
|
|
4
|
+
* and lives in memory only; the inventory is never written anywhere, and only the names of changed paths are reported.
|
|
5
|
+
*/
|
|
6
|
+
import fs from 'node:fs';
|
|
7
|
+
import path from 'node:path';
|
|
8
|
+
import { readJsonc, run, sha256 } from './files.js';
|
|
9
|
+
/** Folders that are never walked: the repository, dependency contents, and the updater's own state. */
|
|
10
|
+
const SKIPPED_DIRS = new Set(['.git', 'node_modules']);
|
|
11
|
+
const UPDATER = ['.meridian/update', '.meridian/history', '.meridian/update.lock'];
|
|
12
|
+
const NEXT_CONFIGS = ['next.config.ts', 'next.config.mts', 'next.config.js', 'next.config.mjs', 'next.config.cjs'];
|
|
13
|
+
const overlaps = (a, b) => a === b || a.startsWith(`${b}/`) || b.startsWith(`${a}/`);
|
|
14
|
+
/** The Next build folder: `distDir` when `next.config` sets it as a string literal, else `.next`. */
|
|
15
|
+
function distDir(root) {
|
|
16
|
+
for (const name of NEXT_CONFIGS) {
|
|
17
|
+
const file = path.join(root, name);
|
|
18
|
+
if (!fs.existsSync(file))
|
|
19
|
+
continue;
|
|
20
|
+
const m = /\bdistDir\s*:\s*(['"`])([^'"`$]+)\1/.exec(fs.readFileSync(file, 'utf8'));
|
|
21
|
+
if (m)
|
|
22
|
+
return m[2];
|
|
23
|
+
}
|
|
24
|
+
return '.next';
|
|
25
|
+
}
|
|
26
|
+
/** The build-info file TypeScript writes: the configured one, or `tsconfig.tsbuildinfo` when the build is incremental. */
|
|
27
|
+
function buildInfo(root) {
|
|
28
|
+
let options;
|
|
29
|
+
try {
|
|
30
|
+
options = readJsonc(path.join(root, 'tsconfig.json')).compilerOptions ?? {};
|
|
31
|
+
}
|
|
32
|
+
catch {
|
|
33
|
+
return null;
|
|
34
|
+
}
|
|
35
|
+
if (typeof options.tsBuildInfoFile === 'string')
|
|
36
|
+
return options.tsBuildInfoFile;
|
|
37
|
+
if (options.incremental !== true && options.composite !== true)
|
|
38
|
+
return null;
|
|
39
|
+
return `${typeof options.outDir === 'string' ? `${options.outDir.replace(/\/+$/, '')}/` : ''}tsconfig.tsbuildinfo`;
|
|
40
|
+
}
|
|
41
|
+
/** The permitted outputs, as project-relative posix paths. A path that leaves the project is reported as a conflict. */
|
|
42
|
+
function outputsOf(root) {
|
|
43
|
+
const outputs = [];
|
|
44
|
+
const conflicts = [];
|
|
45
|
+
const add = (spec, directory) => {
|
|
46
|
+
const abs = path.resolve(root, spec);
|
|
47
|
+
const rel = path.relative(root, abs).split(path.sep).join('/');
|
|
48
|
+
if (rel === '' || rel.startsWith('..') || path.isAbsolute(rel))
|
|
49
|
+
conflicts.push(`${spec}: an output path outside the project is not exempt`);
|
|
50
|
+
else
|
|
51
|
+
outputs.push({ rel, directory });
|
|
52
|
+
};
|
|
53
|
+
add(distDir(root), true);
|
|
54
|
+
add('out', true);
|
|
55
|
+
add('coverage', true);
|
|
56
|
+
const info = buildInfo(root);
|
|
57
|
+
if (info)
|
|
58
|
+
add(info, false);
|
|
59
|
+
const env = path.join(root, 'next-env.d.ts');
|
|
60
|
+
const canonical = !fs.existsSync(env) || fs.readFileSync(env, 'utf8').includes('reference types="next');
|
|
61
|
+
if (canonical)
|
|
62
|
+
add('next-env.d.ts', false);
|
|
63
|
+
return { outputs, conflicts };
|
|
64
|
+
}
|
|
65
|
+
/** True when git tracks a file inside `dir`: an output folder that holds the team's own files is not an output. */
|
|
66
|
+
function tracksFiles(root, dir) {
|
|
67
|
+
if (!fs.existsSync(path.join(root, '.git')))
|
|
68
|
+
return false;
|
|
69
|
+
const r = run('git', ['ls-files', '--', dir], root, true);
|
|
70
|
+
return r.status === 0 && (r.stdout ?? '').trim() !== '';
|
|
71
|
+
}
|
|
72
|
+
/** A symbolic link on the way to `rel` (or at `rel` itself), as the first such project-relative path. */
|
|
73
|
+
function linkOn(root, rel) {
|
|
74
|
+
let cur = root;
|
|
75
|
+
for (const seg of rel.split('/')) {
|
|
76
|
+
cur = path.join(cur, seg);
|
|
77
|
+
if (fs.lstatSync(cur, { throwIfNoEntry: false })?.isSymbolicLink())
|
|
78
|
+
return path.relative(root, cur).split(path.sep).join('/');
|
|
79
|
+
}
|
|
80
|
+
return null;
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* Fingerprints every protected input under `root`. `managed` are the project paths Meridian manages or the team keeps:
|
|
84
|
+
* an output that overlaps one of them is a conflict, as is an output behind a symbolic link or holding tracked files.
|
|
85
|
+
*/
|
|
86
|
+
export function takeInventory(root, managed) {
|
|
87
|
+
const { outputs, conflicts } = outputsOf(root);
|
|
88
|
+
for (const o of outputs) {
|
|
89
|
+
const clash = managed.find((p) => overlaps(p, o.rel));
|
|
90
|
+
if (clash)
|
|
91
|
+
conflicts.push(`${o.rel}: an output path that overlaps ${clash}, a managed or kept file, is not exempt`);
|
|
92
|
+
const link = linkOn(root, o.rel);
|
|
93
|
+
if (link)
|
|
94
|
+
conflicts.push(`${link}: an output path that is a symbolic link is not exempt`);
|
|
95
|
+
else if (o.directory && tracksFiles(root, o.rel))
|
|
96
|
+
conflicts.push(`${o.rel}: an output folder that holds tracked files is not exempt`);
|
|
97
|
+
}
|
|
98
|
+
const exempt = new Set([...UPDATER, ...outputs.map((o) => o.rel)]);
|
|
99
|
+
const prints = new Map();
|
|
100
|
+
const walk = (rel) => {
|
|
101
|
+
for (const e of fs.readdirSync(path.join(root, rel), { withFileTypes: true })) {
|
|
102
|
+
const p = rel ? `${rel}/${e.name}` : e.name;
|
|
103
|
+
if (exempt.has(p))
|
|
104
|
+
continue;
|
|
105
|
+
const abs = path.join(root, p);
|
|
106
|
+
if (e.isDirectory()) {
|
|
107
|
+
if (!SKIPPED_DIRS.has(e.name))
|
|
108
|
+
walk(p);
|
|
109
|
+
}
|
|
110
|
+
else if (e.isSymbolicLink())
|
|
111
|
+
prints.set(p, `link ${fs.readlinkSync(abs)}`);
|
|
112
|
+
else if (e.isFile())
|
|
113
|
+
prints.set(p, sha256(fs.readFileSync(abs)));
|
|
114
|
+
else
|
|
115
|
+
prints.set(p, 'special');
|
|
116
|
+
}
|
|
117
|
+
};
|
|
118
|
+
walk('');
|
|
119
|
+
return { prints, conflicts };
|
|
120
|
+
}
|
|
121
|
+
/** The paths that exist in only one inventory or differ between them, sorted. */
|
|
122
|
+
export function changedPaths(before, after) {
|
|
123
|
+
const out = [];
|
|
124
|
+
for (const [p, h] of after.prints)
|
|
125
|
+
if (before.prints.get(p) !== h)
|
|
126
|
+
out.push(p);
|
|
127
|
+
for (const p of before.prints.keys())
|
|
128
|
+
if (!after.prints.has(p))
|
|
129
|
+
out.push(p);
|
|
130
|
+
return [...new Set(out)].sort();
|
|
131
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `MERGE.md`: the report a person (or their agent) reads to finish an update. Pure text out of the journal, so the same
|
|
3
|
+
* session always renders the same report.
|
|
4
|
+
*/
|
|
5
|
+
import { isNewer } from './update.js';
|
|
6
|
+
/** The sections of a changelog whose version is above `source`, `[Unreleased]` included, each with its own heading line. */
|
|
7
|
+
export function changelogSections(changelog, source) {
|
|
8
|
+
const parts = changelog.split(/^(?=## \[)/m).filter((s) => s.startsWith('## ['));
|
|
9
|
+
return parts.filter((s) => {
|
|
10
|
+
const v = /^## \[([^\]]+)\]/.exec(s)[1];
|
|
11
|
+
return v === 'Unreleased' || (/^\d+\.\d+\.\d+$/.test(v) && isNewer(v, source));
|
|
12
|
+
}).map((s) => s.trimEnd());
|
|
13
|
+
}
|
|
14
|
+
/** A fence long enough that nothing inside the text can close it. */
|
|
15
|
+
function fenced(text) {
|
|
16
|
+
const longest = Math.max(2, ...[...text.matchAll(/`+/g)].map((m) => m[0].length));
|
|
17
|
+
const fence = '`'.repeat(longest + 1);
|
|
18
|
+
return `${fence}markdown\n${text}\n${fence}`;
|
|
19
|
+
}
|
|
20
|
+
const json = (v) => `\`\`\`json\n${JSON.stringify(v, null, 2)}\n\`\`\``;
|
|
21
|
+
/** The path of one staged copy, or `absent` when that side does not exist. */
|
|
22
|
+
const copy = (session, folder, side, path) => (side.exists ? `\`${session}/${folder}/${path}\`` : '`absent`');
|
|
23
|
+
const WHAT = {
|
|
24
|
+
edited: 'You edited this managed file and Meridian changed it too. Combine the two versions.',
|
|
25
|
+
collision: 'Meridian now ships this path and you already have a different file there. Decide which one stays, or combine them.',
|
|
26
|
+
removed: 'Meridian removed this file, and you edited it. Decide whether to delete it or keep it.',
|
|
27
|
+
'local-deletion': 'You deleted this managed file and Meridian still ships it. Decide whether it stays out or comes back.',
|
|
28
|
+
};
|
|
29
|
+
export function renderMergeReport(i) {
|
|
30
|
+
const j = i.journal;
|
|
31
|
+
const finalize = `npx zz-meridian@${j.targetVersion} update --finalize`;
|
|
32
|
+
const resume = `npx zz-meridian@${j.targetVersion} update --resume`;
|
|
33
|
+
const abort = `npx zz-meridian@${j.targetVersion} update --abort`;
|
|
34
|
+
const staged = j.operations.filter((o) => o.action === 'stage');
|
|
35
|
+
const out = [];
|
|
36
|
+
out.push(`# Update ${j.sourceVersion} to ${j.targetVersion}`, '');
|
|
37
|
+
out.push(`- Source: ${j.sourceVersion}`, `- Target: ${j.targetVersion}`, `- Route: ${j.route}`, `- Session: ${j.id}`, `- Phase: ${j.phase}`, `- Outcome: ${i.outcome}`, '');
|
|
38
|
+
out.push('Commands, pinned to the release that started this session:', '', '```', `${finalize} # when every item below is resolved`, `${resume} # after an interruption, or to run a skipped install`, `${abort} # restore what the update changed and archive the session`, '```', '');
|
|
39
|
+
if (j.failure)
|
|
40
|
+
out.push('## Failure', '', j.failure, '');
|
|
41
|
+
out.push('## What needs you', '');
|
|
42
|
+
if (staged.length === 0 && j.migrations.length === 0)
|
|
43
|
+
out.push('Nothing was staged and no migration is required.', '');
|
|
44
|
+
out.push('Resolve each item, then record it in `resolutions.json` next to this file. A resolution is an object with the item id, `resolved`, a reason in a sentence, and the hash each named file has right now. Compute a hash as `sha256-` plus the file\'s SHA-256 (`shasum -a 256 <file>`), or `null` for a file that is absent. A file edited after its resolution needs a new one.', '');
|
|
45
|
+
for (const o of staged)
|
|
46
|
+
out.push(...stagedItem(i, o));
|
|
47
|
+
for (const m of j.migrations) {
|
|
48
|
+
out.push(`### migration:${m.id}`, '', m.summary, '', `Paths: ${m.paths.map((p) => `\`${p}\``).join(', ') || 'none'}`, '', m.instructions, '');
|
|
49
|
+
if (m.checks.length)
|
|
50
|
+
out.push('Checks:', '', ...m.checks.map((c) => `- \`${c}\``), '');
|
|
51
|
+
out.push('Resolve with `resolved` or `not-applicable`:', '', json({ id: `migration:${m.id}`, status: 'resolved', reason: '', files: Object.fromEntries(m.paths.map((p) => [p, i.hashOf(p)])) }), '');
|
|
52
|
+
}
|
|
53
|
+
const warnings = [
|
|
54
|
+
...i.keepWarnings,
|
|
55
|
+
...j.operations.filter((o) => o.disposition === 'retired-kept').map((o) => `${o.path}: Meridian removed this file; your kept copy stays and is now yours alone. It is recorded as retired.`),
|
|
56
|
+
];
|
|
57
|
+
out.push('## Kept and retired files', '');
|
|
58
|
+
out.push(...(warnings.length ? warnings.map((w) => `- ${w}`) : ['None.']), '');
|
|
59
|
+
const team = [...new Set([...j.operations.filter((o) => o.disposition === 'team-preserved' && o.action === 'write').map((o) => o.path), ...j.migrations.flatMap((m) => m.paths)])].sort();
|
|
60
|
+
out.push('## Team files this update touches or asks you to review', '');
|
|
61
|
+
out.push(...(team.length ? team.map((p) => `- \`${p}\``) : ['None.']), '', 'Meridian changes a team file only where it says so here: `package.json` (dependencies and scripts), `AGENTS.md` (the managed block) and the lockfile after an install. Everything else is advice in the items above.', '');
|
|
62
|
+
const counts = new Map();
|
|
63
|
+
for (const o of j.operations)
|
|
64
|
+
counts.set(`${o.disposition}/${o.action}`, (counts.get(`${o.disposition}/${o.action}`) ?? 0) + 1);
|
|
65
|
+
out.push('## Every disposition', '', ...[...counts].sort().map(([k, n]) => `- ${k}: ${n}`), '', '<details><summary>Every path</summary>', '', '```');
|
|
66
|
+
for (const o of j.operations)
|
|
67
|
+
out.push(`${o.disposition.padEnd(15)} ${o.action.padEnd(6)} ${o.path}`);
|
|
68
|
+
out.push('```', '', '</details>', '');
|
|
69
|
+
out.push('## Verify', '', 'Finalizing runs the project gate and one build. To check by hand first:', '', '```', `${i.pm} run gate`, 'node node_modules/next/dist/bin/next build', '```', '');
|
|
70
|
+
const sections = changelogSections(i.changelog, j.sourceVersion);
|
|
71
|
+
out.push('## Changelog', '', sections.length ? `The target release's changelog sections above ${j.sourceVersion}:` : `The target release has no changelog section above ${j.sourceVersion}.`, '');
|
|
72
|
+
if (sections.length)
|
|
73
|
+
out.push(fenced(sections.join('\n\n')), '');
|
|
74
|
+
return `${out.join('\n')}\n`;
|
|
75
|
+
}
|
|
76
|
+
function stagedItem(i, o) {
|
|
77
|
+
const s = i.session;
|
|
78
|
+
return [
|
|
79
|
+
`### file:${o.path}`, '',
|
|
80
|
+
`${o.disposition}: ${WHAT[o.disposition] ?? 'Review this file.'}`, '',
|
|
81
|
+
`- base: ${copy(s, 'base', o.base, o.path)}`,
|
|
82
|
+
`- ours: ${copy(s, 'ours', o.ours, o.path)}`,
|
|
83
|
+
`- new: ${copy(s, 'new', o.target, o.path)}`, '',
|
|
84
|
+
'Resolution stub:', '',
|
|
85
|
+
json({ id: `file:${o.path}`, status: 'resolved', reason: '', files: { [o.path]: i.hashOf(o.path) } }), '',
|
|
86
|
+
];
|
|
87
|
+
}
|