@dastyaresocial/ds-cs 0.0.0-stage → 0.1.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 +134 -2
- package/bin/ds-cs.mjs +14 -0
- package/package.json +33 -4
- package/skills/README.md +71 -0
- package/skills/dev/add-component/SKILL.md +166 -0
- package/skills/dev/add-orpc-endpoint/SKILL.md +143 -0
- package/skills/dev/db-migration/SKILL.md +104 -0
- package/skills/dev/release/SKILL.md +106 -0
- package/skills/product/brand-config/SKILL.md +98 -0
- package/skills/product/manage-stories/SKILL.md +119 -0
- package/skills/product/mcp-connect/SKILL.md +116 -0
- package/skills/product/post-with-media/SKILL.md +197 -0
- package/skills/product/self-host-deploy/SKILL.md +112 -0
- package/skills/product/webhooks/SKILL.md +160 -0
- package/src/agents.mjs +130 -0
- package/src/args.mjs +111 -0
- package/src/auth.mjs +82 -0
- package/src/commands/add.mjs +118 -0
- package/src/commands/auth.mjs +87 -0
- package/src/commands/init.mjs +106 -0
- package/src/commands/list.mjs +89 -0
- package/src/commands/mcp.mjs +101 -0
- package/src/commands/remove.mjs +80 -0
- package/src/commands/search.mjs +51 -0
- package/src/commands/target.mjs +83 -0
- package/src/commands/update.mjs +94 -0
- package/src/index.mjs +176 -0
- package/src/install.mjs +174 -0
- package/src/lockfile.mjs +74 -0
- package/src/mcp-config.mjs +39 -0
- package/src/registry/archive.mjs +209 -0
- package/src/registry/fetch.mjs +114 -0
- package/src/registry/sources.mjs +395 -0
- package/src/skills/discover.mjs +170 -0
- package/src/skills/frontmatter.mjs +85 -0
- package/src/utils/errors.mjs +13 -0
- package/src/utils/fs.mjs +106 -0
- package/src/utils/log.mjs +85 -0
- package/src/utils/prompt.mjs +69 -0
package/README.md
CHANGED
|
@@ -1,3 +1,135 @@
|
|
|
1
|
-
#
|
|
1
|
+
# ds-cs
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
The CLI for [Dastyare Social — CS](https://cs.dastyare.social). Installs agent skills, wires up MCP, and authenticates to a channel.
|
|
4
|
+
|
|
5
|
+
```bash
|
|
6
|
+
npx ds-cs init
|
|
7
|
+
npx ds-cs add post-with-media
|
|
8
|
+
```
|
|
9
|
+
|
|
10
|
+
## Why
|
|
11
|
+
|
|
12
|
+
DS-CS already speaks two agent protocols — a REST API and an MCP server — but neither tells an agent *how* to use them. The constraints that matter (upload before you post; `kind=` filters stories while `type=` counts them; the oRPC URL must be absolute) are scattered across a README an agent will not read.
|
|
13
|
+
|
|
14
|
+
Skills are that knowledge, in the form agents actually load. This CLI installs them.
|
|
15
|
+
|
|
16
|
+
## Commands
|
|
17
|
+
|
|
18
|
+
| Command | What it does |
|
|
19
|
+
|---|---|
|
|
20
|
+
| `ds-cs init` | Create skills directories, optionally write MCP config |
|
|
21
|
+
| `ds-cs add <skill\|source>` | Install skills from DS-CS or anywhere else |
|
|
22
|
+
| `ds-cs list` | Show what is installed and where it came from |
|
|
23
|
+
| `ds-cs update [skill…]` | Re-fetch skills that changed upstream |
|
|
24
|
+
| `ds-cs remove <skill…>` | Uninstall from every agent |
|
|
25
|
+
| `ds-cs search [query]` | Search installed skills |
|
|
26
|
+
| `ds-cs mcp [agent]` | Print, or write, MCP server config |
|
|
27
|
+
| `ds-cs login` | Store a channel URL and API key |
|
|
28
|
+
| `ds-cs whoami` | Show the stored channel |
|
|
29
|
+
| `ds-cs logout` | Remove stored credentials |
|
|
30
|
+
|
|
31
|
+
## Sources
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
ds-cs add brand-config # from the DS-CS registry (the default)
|
|
35
|
+
ds-cs add owner/repo # any GitHub skills repo
|
|
36
|
+
ds-cs add owner/repo@skill # one skill from that repo
|
|
37
|
+
ds-cs add owner/repo@a,b # several
|
|
38
|
+
ds-cs add https://github.com/o/r/tree/main/skills/x
|
|
39
|
+
ds-cs add ./my-skills # a local directory
|
|
40
|
+
ds-cs add https://…/SKILL.md # a direct URL
|
|
41
|
+
ds-cs add https://…/archive.tar.gz # a zip or tarball of skills
|
|
42
|
+
ds-cs add git@github.com:o/r.git # any other git host
|
|
43
|
+
ds-cs add owner/repo --list # browse without installing
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
A bare word is a skill name from the DS-CS registry, because knowing where a skill lives should not be the user's problem. Anything source-shaped — `owner/repo`, a URL, a path — is treated as a source.
|
|
47
|
+
|
|
48
|
+
GitHub repositories are fetched as tarballs rather than cloned: one HTTP request, no `git` binary required, and no repository hooks executed. Only non-GitHub git hosts fall back to `git clone`.
|
|
49
|
+
|
|
50
|
+
## Where skills land
|
|
51
|
+
|
|
52
|
+
The canonical copy always goes to `.agents/skills/<name>` — the directory OpenCode, Cursor, Codex and a dozen others already read. Every other target is symlinked to it, so an update lands everywhere at once and there is one source of truth per project.
|
|
53
|
+
|
|
54
|
+
```bash
|
|
55
|
+
ds-cs add brand-config -a claude-code # also link into .claude/skills
|
|
56
|
+
ds-cs add brand-config -a ./my-agent/skills # or any directory you name
|
|
57
|
+
ds-cs add brand-config --copy # copy instead of symlink
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
Symlinks are the default because agents that disagree about skill content is a worse problem than agents that disagree about inode counts. `--copy` is there for filesystems where symlinks do not work; the CLI falls back to copying automatically if a symlink fails.
|
|
61
|
+
|
|
62
|
+
Run `ds-cs init --list-agents` for the full table.
|
|
63
|
+
|
|
64
|
+
## Lockfile
|
|
65
|
+
|
|
66
|
+
Installs are recorded in `skills-lock.json`, in the same v1 format `npx skills` (vercel-labs) reads and writes. The two tools interoperate in one project without clobbering each other:
|
|
67
|
+
|
|
68
|
+
```bash
|
|
69
|
+
ds-cs add brand-config # DS-CS skills
|
|
70
|
+
npx skills add emilkowalski/skill # third-party skills
|
|
71
|
+
ds-cs list # reports both
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
Each entry records the source, the skill's path within it, and a content hash. `ds-cs update` compares against that hash and **refuses to overwrite a skill you have edited locally** — an agent's tweak to a skill is usually deliberate. `--force` overrides.
|
|
75
|
+
|
|
76
|
+
## MCP
|
|
77
|
+
|
|
78
|
+
```bash
|
|
79
|
+
ds-cs login --url https://cs.example.com --api-key "$API_KEY"
|
|
80
|
+
ds-cs mcp # print every client format
|
|
81
|
+
ds-cs mcp claude-code # write .mcp.json
|
|
82
|
+
ds-cs mcp opencode # write opencode.json
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
An existing config file is never overwritten. The snippet is printed for you to merge — clobbering a working MCP config to add one server is a bad trade.
|
|
86
|
+
|
|
87
|
+
Remote (HTTP) works everywhere and is what most people want. Local (stdio, `bun run mcp`) is faster and works offline but needs a clone with `DATABASE_URL` set.
|
|
88
|
+
|
|
89
|
+
## Credentials
|
|
90
|
+
|
|
91
|
+
`ds-cs login` verifies the channel before saving, so a mistyped key surfaces at login rather than mid-task inside an agent. Credentials land in `~/.config/ds-cs/auth.json` at mode `0600` — outside the project, because a key that can delete content is one `git add -A` away from being published.
|
|
92
|
+
|
|
93
|
+
```bash
|
|
94
|
+
ds-cs whoami # shows a redacted key
|
|
95
|
+
ds-cs logout
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
`DS_CS_URL` and `DS_CS_API_KEY` override the stored values. `DS_CS_CONFIG_DIR` relocates the file.
|
|
99
|
+
|
|
100
|
+
## Where the registry comes from
|
|
101
|
+
|
|
102
|
+
`ds-cs add` resolves the DS-CS registry in this order:
|
|
103
|
+
|
|
104
|
+
1. **A local checkout** — a DS-CS repo found by walking up from the current directory. Edit a skill and reinstall it and you get your edit, not whatever `main` says.
|
|
105
|
+
2. **The copy bundled in this package** — `prepack` copies `skills/` into the published package. This is the normal path for an installed CLI, and it works with no network at all. It also guarantees the registry matches the CLI version you have.
|
|
106
|
+
3. **GitHub** — a fallback for a CLI older than the skills it wants. Only the `skills/` subtree is fetched; the full repo tarball is ~28MB, almost all of it `public/` and generated assets, and reading it to get ten markdown files is not a trade worth making.
|
|
107
|
+
|
|
108
|
+
The repo's `skills/` is the source of truth. The copy inside this package is a build artefact, regenerated on `prepack` and gitignored.
|
|
109
|
+
|
|
110
|
+
## Publishing a skill to the registry
|
|
111
|
+
|
|
112
|
+
DS-CS skills live in [`skills/`](../../skills) in this repository. A skill is a directory with a `SKILL.md` whose frontmatter carries a `name` and a `description`:
|
|
113
|
+
|
|
114
|
+
```
|
|
115
|
+
skills/
|
|
116
|
+
dev/add-orpc-endpoint/SKILL.md
|
|
117
|
+
product/post-with-media/SKILL.md
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
The description is the whole interface — it is what an agent reads to decide whether to load the skill. Write it as "use when X", and name the sibling skills it should *not* be used for. Reference files, scripts and templates sit next to `SKILL.md` in the same directory and travel with it.
|
|
121
|
+
|
|
122
|
+
Set `metadata.internal: true` to hide a skill from discovery until `INSTALL_INTERNAL_SKILLS=1`.
|
|
123
|
+
|
|
124
|
+
## Development
|
|
125
|
+
|
|
126
|
+
```bash
|
|
127
|
+
npm test # node:test, no dependencies
|
|
128
|
+
npm run dev -- --help
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
While working inside a DS-CS checkout, `ds-cs add` prefers the local `skills/` directory over the published registry, so edits reinstall immediately. Pass a source path explicitly to bypass that.
|
|
132
|
+
|
|
133
|
+
## Requirements
|
|
134
|
+
|
|
135
|
+
Node 18.17+. Zero runtime dependencies.
|
package/bin/ds-cs.mjs
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { main } from "../src/index.mjs";
|
|
3
|
+
|
|
4
|
+
main(process.argv.slice(2)).catch((error) => {
|
|
5
|
+
// Anything reaching here is a bug in the CLI, not a user error. Report it in
|
|
6
|
+
// a shape a maintainer can act on, then exit non-zero so scripts can branch.
|
|
7
|
+
if (process.env.DS_CS_DEBUG) {
|
|
8
|
+
console.error(error);
|
|
9
|
+
} else {
|
|
10
|
+
console.error(`\nds-cs: ${error?.message ?? error}`);
|
|
11
|
+
if (error?.hint) console.error(`hint: ${error.hint}`);
|
|
12
|
+
}
|
|
13
|
+
process.exit(1);
|
|
14
|
+
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,35 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dastyaresocial/ds-cs",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "CLI for Dastyare Social — CS. Install DS-CS skills and third-party agent skills, wire up MCP, and authenticate to a channel.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"bin": {
|
|
8
|
+
"ds-cs": "bin/ds-cs.mjs"
|
|
9
|
+
},
|
|
10
|
+
"files": [
|
|
11
|
+
"bin",
|
|
12
|
+
"src",
|
|
13
|
+
"skills",
|
|
14
|
+
"README.md"
|
|
15
|
+
],
|
|
16
|
+
"engines": {
|
|
17
|
+
"node": ">=18.17"
|
|
18
|
+
},
|
|
19
|
+
"scripts": {
|
|
20
|
+
"test": "node --test test/*.test.mjs",
|
|
21
|
+
"dev": "node ./bin/ds-cs.mjs",
|
|
22
|
+
"sync-skills": "node ./scripts/sync-skills.mjs",
|
|
23
|
+
"prepack": "node ./scripts/sync-skills.mjs"
|
|
24
|
+
},
|
|
25
|
+
"keywords": [
|
|
26
|
+
"ds-cs",
|
|
27
|
+
"dastyare-social",
|
|
28
|
+
"agent-skills",
|
|
29
|
+
"skills",
|
|
30
|
+
"mcp",
|
|
31
|
+
"cli",
|
|
32
|
+
"ai"
|
|
33
|
+
],
|
|
34
|
+
"dependencies": {}
|
|
35
|
+
}
|
package/skills/README.md
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
# DS-CS agent skills
|
|
2
|
+
|
|
3
|
+
First-party skills for [Dastyare Social — CS](https://cs.dastyare.social). Install them with:
|
|
4
|
+
|
|
5
|
+
```bash
|
|
6
|
+
npx ds-cs init
|
|
7
|
+
npx ds-cs add # browse everything
|
|
8
|
+
npx ds-cs add post-with-media # or install one by name
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## What is here
|
|
12
|
+
|
|
13
|
+
### Working on the codebase — `dev/`
|
|
14
|
+
|
|
15
|
+
For agents and humans changing DS-CS itself.
|
|
16
|
+
|
|
17
|
+
| Skill | Covers |
|
|
18
|
+
|---|---|
|
|
19
|
+
| [`dev/add-orpc-endpoint`](dev/add-orpc-endpoint) | Adding a procedure to the internal oRPC router |
|
|
20
|
+
| [`dev/add-component`](dev/add-component) | Components without breaking the design system |
|
|
21
|
+
| [`dev/db-migration`](dev/db-migration) | Drizzle schema changes and migrations |
|
|
22
|
+
| [`dev/release`](dev/release) | Version bump, annotated tag, push |
|
|
23
|
+
|
|
24
|
+
### Operating a channel — `product/`
|
|
25
|
+
|
|
26
|
+
For agents acting *on* a live channel.
|
|
27
|
+
|
|
28
|
+
| Skill | Covers |
|
|
29
|
+
|---|---|
|
|
30
|
+
| [`product/post-with-media`](product/post-with-media) | Posts, media upload, pinning |
|
|
31
|
+
| [`product/manage-stories`](product/manage-stories) | Ephemeral stories |
|
|
32
|
+
| [`product/webhooks`](product/webhooks) | Signed event delivery |
|
|
33
|
+
| [`product/brand-config`](product/brand-config) | Channel identity and the `/about` page |
|
|
34
|
+
| [`product/self-host-deploy`](product/self-host-deploy) | Install, update, back up |
|
|
35
|
+
| [`product/mcp-connect`](product/mcp-connect) | Connecting an agent over MCP |
|
|
36
|
+
|
|
37
|
+
## Writing one
|
|
38
|
+
|
|
39
|
+
A skill is a directory containing `SKILL.md`:
|
|
40
|
+
|
|
41
|
+
```markdown
|
|
42
|
+
---
|
|
43
|
+
name: my-skill
|
|
44
|
+
description: What it does and, more importantly, when to reach for it.
|
|
45
|
+
---
|
|
46
|
+
|
|
47
|
+
# My Skill
|
|
48
|
+
|
|
49
|
+
Instructions.
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
The `description` is the entire interface. It is the only thing an agent sees before deciding whether to load the skill, so it has to answer "is this my problem?" in one line. Write it as *use when…*, and name the neighbouring skills that should be used instead — an agent that loads the wrong skill is worse than one that loads none.
|
|
53
|
+
|
|
54
|
+
Keep `SKILL.md` to instructions. Reference material, scripts and templates go in the same directory under their own names; they travel with the skill and are loaded only when the instructions point at them.
|
|
55
|
+
|
|
56
|
+
Add `metadata.internal: true` to hide a skill from discovery until `INSTALL_INTERNAL_SKILLS=1`.
|
|
57
|
+
|
|
58
|
+
## Testing a change
|
|
59
|
+
|
|
60
|
+
While inside a DS-CS checkout, `ds-cs add` installs from this directory rather than from GitHub, so edits take effect immediately:
|
|
61
|
+
|
|
62
|
+
```bash
|
|
63
|
+
npx ds-cs add brand-config --force
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
Run it from a scratch directory to test what a user actually gets:
|
|
67
|
+
|
|
68
|
+
```bash
|
|
69
|
+
mkdir /tmp/t && cd /tmp/t && git init -q
|
|
70
|
+
node /path/to/ds-cs/scripts/cli/bin/ds-cs.mjs add brand-config
|
|
71
|
+
```
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: add-component
|
|
3
|
+
description: Add or change a React component in DS-CS without breaking the design system — token architecture, the no-shadow and 60% corner-smoothing global rules, liquid-glass usage, cva variants, and the "use client" boundary. Use when adding a UI component, restyling one, or reviewing a component for design-rule violations. Read docs/design-system.md before deviating from these rules.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Adding a component
|
|
7
|
+
|
|
8
|
+
## Read the two global rules first
|
|
9
|
+
|
|
10
|
+
These are enforced for the **whole codebase**, not per component. Violating them is the most common way a change gets rejected.
|
|
11
|
+
|
|
12
|
+
**1. No shadows, anywhere.** No `box-shadow`, no `text-shadow`, no `drop-shadow` filter. Surfaces sit flush with the page and never lift off it. `src/styles/globals.css` declares an *unlayered* reset so a `shadow-*` utility cannot override it — which means a stray `shadow-lg` in a `className` compiles, type-checks, and renders nothing. Remove those classes for clarity even though they are inert; they misdescribe intent.
|
|
13
|
+
|
|
14
|
+
The single exception is `.liquid-glass`, and only as an **inset** stroke. Inset shadows fade inward and cannot cast outside the element, so the no-depth rule still holds.
|
|
15
|
+
|
|
16
|
+
**2. Rounded content surfaces use 60% corner smoothing.** `border-radius` controls corner *size* (`rounded-xl`); `corner-shape` controls corner *shape* (the squircle). `superellipse()` takes an **exponent, not a percentage**: `2` is a plain circular arc, `2.5` is the continuous iOS/Figma corner, and anything **below `2` cuts the corner inward** — the opposite of smoothing. The app uses `2.5` everywhere via `--corner-smoothing`. It is declared once against every element except `.rounded-full` (true circles stay circles — smoothing pushes them to a square, squaring off every avatar, pill and round button) and `.w-full` (full-bleed bars — install/update banners, the pinned bar — keep circular corners). Never restate `superellipse()` in a component; override `--corner-smoothing` and say why.
|
|
17
|
+
|
|
18
|
+
## Tokens
|
|
19
|
+
|
|
20
|
+
Two layers, and the split is the point:
|
|
21
|
+
|
|
22
|
+
- Raw values live in `:root` in `src/styles/globals.css`.
|
|
23
|
+
- `@theme inline` re-exports them under Tailwind's colour namespace so utilities resolve to the live value instead of a build-time copy.
|
|
24
|
+
|
|
25
|
+
**Add to `:root`.** Add an `@theme inline` mapping only when a Tailwind utility should reach it.
|
|
26
|
+
|
|
27
|
+
```css
|
|
28
|
+
:root {
|
|
29
|
+
--button-background: color-mix(in srgb, var(--primary) 10%, transparent);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
@theme inline {
|
|
33
|
+
--color-button-background: var(--button-background);
|
|
34
|
+
}
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
Never hardcode a raw colour (`#fff`, `oklch(0.65 0.19 41)`) in a component. Severity and hierarchy come from blur, translucency, wash gradients, rim borders and contrast — never from elevation.
|
|
38
|
+
|
|
39
|
+
## The shape of a component
|
|
40
|
+
|
|
41
|
+
`src/components/button.tsx` is the reference implementation:
|
|
42
|
+
|
|
43
|
+
```tsx
|
|
44
|
+
import { Slot } from "@radix-ui/react-slot";
|
|
45
|
+
import { cva, type VariantProps } from "class-variance-authority";
|
|
46
|
+
import type * as React from "react";
|
|
47
|
+
|
|
48
|
+
import { cn } from "@/lib/utils";
|
|
49
|
+
|
|
50
|
+
const buttonVariants = cva(
|
|
51
|
+
"inline-flex items-center justify-center rounded-full liquid-glass transition-all disabled:pointer-events-none disabled:opacity-60 outline-none cursor-pointer select-none text-xl md:text-2xl px-5 py-2",
|
|
52
|
+
{
|
|
53
|
+
variants: {
|
|
54
|
+
variant: {
|
|
55
|
+
primary:
|
|
56
|
+
"[--liquid-glass-bg:var(--button-background)] [--liquid-glass-border-color:var(--button-border)] text-button-foreground hover:opacity-60",
|
|
57
|
+
secondary:
|
|
58
|
+
"[--liquid-glass-bg:color-mix(in_srgb,var(--button-background)_20%,transparent)] [--liquid-glass-border-width:2px] [--liquid-glass-border-color:color-mix(in_srgb,var(--button-border)_50%,transparent)] text-button-foreground hover:opacity-60",
|
|
59
|
+
},
|
|
60
|
+
},
|
|
61
|
+
defaultVariants: { variant: "primary" },
|
|
62
|
+
}
|
|
63
|
+
);
|
|
64
|
+
|
|
65
|
+
function Button({
|
|
66
|
+
className,
|
|
67
|
+
variant = "primary",
|
|
68
|
+
asChild = false,
|
|
69
|
+
...props
|
|
70
|
+
}: React.ComponentProps<"button"> &
|
|
71
|
+
VariantProps<typeof buttonVariants> & {
|
|
72
|
+
asChild?: boolean;
|
|
73
|
+
}) {
|
|
74
|
+
const Comp = asChild ? Slot : "button";
|
|
75
|
+
|
|
76
|
+
return (
|
|
77
|
+
<Comp
|
|
78
|
+
data-slot="button"
|
|
79
|
+
data-variant={variant}
|
|
80
|
+
className={cn(buttonVariants({ variant, className }))}
|
|
81
|
+
{...props}
|
|
82
|
+
/>
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export { Button, buttonVariants };
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
The conventions in it:
|
|
90
|
+
|
|
91
|
+
- Variants live in `cva`, not in `className` strings scattered across call sites.
|
|
92
|
+
- Glass is tinted by **overriding tokens** (`[--liquid-glass-bg:...]`), never by restating the effect.
|
|
93
|
+
- `data-slot` and `data-variant` are set so tests and Storybook can target elements without relying on classes.
|
|
94
|
+
- `cn()` from `@/lib/utils` is `twMerge(clsx(...))` — always use it for class composition.
|
|
95
|
+
- `asChild` via Radix `Slot` lets a component render as its child (`<Button asChild><Link/></Button>`). Preserve it when the element is interactive or navigable.
|
|
96
|
+
|
|
97
|
+
## The client boundary
|
|
98
|
+
|
|
99
|
+
Components that fetch call the **action layer**, never oRPC directly:
|
|
100
|
+
|
|
101
|
+
```tsx
|
|
102
|
+
"use client";
|
|
103
|
+
|
|
104
|
+
import { getPosts } from "@/lib/actions/posts";
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
Server-side callers — the OG image route, for instance — import `@/lib/api/*` directly and never touch the RPC client. A relative `/api/rpc` URL has no meaning on the server.
|
|
108
|
+
|
|
109
|
+
## Liquid glass
|
|
110
|
+
|
|
111
|
+
`.liquid-glass` is the reusable translucent material for buttons, cards, panels and any surface that should read as glass. It is made of exactly four things, in order: `backdrop-filter: blur() saturate()`, a vertical wash gradient, a translucent background, and a rim from border width + colour.
|
|
112
|
+
|
|
113
|
+
```tsx
|
|
114
|
+
<div className="liquid-glass rounded-2xl px-6 py-4">…</div>
|
|
115
|
+
<div className="liquid-glass [--liquid-glass-bg:color-mix(in_srgb,var(--primary)_22%,transparent)]" />
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
The stroke and the border are separate and complementary. `--liquid-glass-border-color` sets edge weight to match the install/update banners; the inset stroke adds the luminous rim. Do not merge them.
|
|
119
|
+
|
|
120
|
+
Glass is invisible on a flat white background. Review it over a busy backdrop using the `Design System/Liquid Glass` Storybook story, not an isolated white canvas.
|
|
121
|
+
|
|
122
|
+
## Tests and stories
|
|
123
|
+
|
|
124
|
+
Component tests live in `src/components/__tests__/<name>.test.tsx` and use `bun:test`, `@testing-library/react`, and a `happy-dom` window set up per test:
|
|
125
|
+
|
|
126
|
+
```tsx
|
|
127
|
+
import { describe, it, expect, beforeEach } from "bun:test";
|
|
128
|
+
import { render } from "@testing-library/react";
|
|
129
|
+
import { Button } from "../button";
|
|
130
|
+
import React from "react";
|
|
131
|
+
import "@testing-library/jest-dom/vitest";
|
|
132
|
+
import { Window } from "happy-dom";
|
|
133
|
+
|
|
134
|
+
beforeEach(() => {
|
|
135
|
+
const window = new Window();
|
|
136
|
+
globalThis.window = window as any;
|
|
137
|
+
globalThis.document = window.document as any;
|
|
138
|
+
globalThis.navigator = window.navigator as any;
|
|
139
|
+
document.body.innerHTML = "";
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
describe("Button Component", () => {
|
|
143
|
+
it("renders with primary variant by default", () => {
|
|
144
|
+
const { getByText } = render(<Button>Click me</Button>);
|
|
145
|
+
expect(getByText("Click me")).toHaveAttribute("data-variant", "primary");
|
|
146
|
+
});
|
|
147
|
+
});
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
`test-setup.ts` mocks `next/font/local` globally so components can use local fonts under test.
|
|
151
|
+
|
|
152
|
+
Stories go in `src/components/<name>.stories.tsx` under the `Design System/*` namespace.
|
|
153
|
+
|
|
154
|
+
## Accessibility
|
|
155
|
+
|
|
156
|
+
Radix primitives are already a dependency — use them instead of hand-rolling behaviour. Anything that opens a surface is a dialog or a context menu, not a `div` with `onClick`. Every interactive element needs a visible focus state and an accessible name.
|
|
157
|
+
|
|
158
|
+
## Verify
|
|
159
|
+
|
|
160
|
+
```bash
|
|
161
|
+
bun run test
|
|
162
|
+
bun run lint
|
|
163
|
+
bun run storybook # then look at your component against the design-system stories
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
For animations, read the `animate` skill — duration, curve and interruptibility are decisions, not defaults.
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: add-orpc-endpoint
|
|
3
|
+
description: Add an internal oRPC procedure to the DS-CS router — the typed channel between the React client and the server. Use when adding a posts/stories procedure, exposing new server logic to the UI, or when an oRPC call 404s at runtime. For external/public endpoints use the REST layer under src/app/api instead — this skill is internal-only.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Adding an oRPC procedure
|
|
7
|
+
|
|
8
|
+
## What this layer is
|
|
9
|
+
|
|
10
|
+
oRPC is the **internal, typed RPC channel between the React client and the server** for posts and stories. It is not a public API — external integrations and AI agents use REST under `src/app/api`. Do not add external-facing endpoints here.
|
|
11
|
+
|
|
12
|
+
The transport path, end to end:
|
|
13
|
+
|
|
14
|
+
```
|
|
15
|
+
"use client" component
|
|
16
|
+
│ import { getPosts } from "@/lib/actions/posts"
|
|
17
|
+
▼
|
|
18
|
+
src/lib/actions/posts.ts thin 1:1 wrapper, owns analytics calls
|
|
19
|
+
│ orpc.posts.list(input)
|
|
20
|
+
▼
|
|
21
|
+
RPCLink → POST /api/rpc/posts/list
|
|
22
|
+
▼
|
|
23
|
+
src/app/api/rpc/[...path]/route.ts RPCHandler
|
|
24
|
+
▼
|
|
25
|
+
src/lib/orpc/router.ts validates input, calls business logic
|
|
26
|
+
▼
|
|
27
|
+
src/lib/api/posts/*.ts the actual work
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
## The five rules
|
|
31
|
+
|
|
32
|
+
1. **Business logic goes in `src/lib/api/*`.** Never inline a database query in the router. The router validates and delegates.
|
|
33
|
+
2. **Components import from `@/lib/actions/*`, never `@/lib/orpc/client`.** Transport concerns in a component make the client impossible to swap later.
|
|
34
|
+
3. **One procedure, one action wrapper, 1:1.** No action wraps two procedures; no procedure has two actions.
|
|
35
|
+
4. **Declare an input schema only if the procedure takes arguments.** `os.handler(...)` with no `.input()` for zero-arg procedures.
|
|
36
|
+
5. **Add a test** to `src/lib/orpc/router.test.ts`.
|
|
37
|
+
|
|
38
|
+
## Step 1 — the business logic
|
|
39
|
+
|
|
40
|
+
Add to the matching module, `src/lib/api/posts/index.ts` or `src/lib/api/stories/index.ts`:
|
|
41
|
+
|
|
42
|
+
```ts
|
|
43
|
+
export async function getPostStats(id: string) {
|
|
44
|
+
const rows = await db.select().from(posts).where(eq(posts.id, id));
|
|
45
|
+
if (rows.length === 0) {
|
|
46
|
+
throw new Error("Post not found");
|
|
47
|
+
}
|
|
48
|
+
return { views: rows[0].views, media: rows[0].media };
|
|
49
|
+
}
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
Throw a plain `Error` for the not-found case. Procedures throw plain `Error` throughout this codebase; oRPC masks the message as `INTERNAL_SERVER_ERROR` for the client, and no UI reads the text. If you need a machine-readable code, declare errors with `os.errors({ ... })` instead of relying on message text.
|
|
53
|
+
|
|
54
|
+
## Step 2 — the procedure
|
|
55
|
+
|
|
56
|
+
Add to `postsRouter` or `storiesRouter` in `src/lib/orpc/router.ts`. Import the business function at the top of the file alongside its siblings.
|
|
57
|
+
|
|
58
|
+
With input:
|
|
59
|
+
|
|
60
|
+
```ts
|
|
61
|
+
getStats: os.input(postIdInput).handler(async ({ input }) => {
|
|
62
|
+
return getPostStats(input.id);
|
|
63
|
+
}),
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
Reuse an existing input schema when one fits — `postIdInput` is `z.object({ id: z.string().min(1) })`. Define a new one near the others only when the shape is genuinely different:
|
|
67
|
+
|
|
68
|
+
```ts
|
|
69
|
+
const postStatsInput = z.object({
|
|
70
|
+
id: z.string().min(1),
|
|
71
|
+
includeDrafts: z.boolean().optional(),
|
|
72
|
+
});
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
Without input:
|
|
76
|
+
|
|
77
|
+
```ts
|
|
78
|
+
count: os.handler(async () => ({ total: await countPosts() })),
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
Input schemas are plain Zod v4 objects passed straight to `os.input()`. Zod 4 implements Standard Schema v1 natively, so there is no adapter package to import.
|
|
82
|
+
|
|
83
|
+
**Output schemas are intentionally not declared.** Adding one is a behaviour change — oRPC will validate and strip on output. Do not add one as a drive-by.
|
|
84
|
+
|
|
85
|
+
## Step 3 — the action wrapper
|
|
86
|
+
|
|
87
|
+
Add to `src/lib/actions/posts.ts` (or `stories.ts`). Thin, 1:1, with the same defaulting the sibling wrappers do:
|
|
88
|
+
|
|
89
|
+
```ts
|
|
90
|
+
export async function getPostStats(id: string) {
|
|
91
|
+
const result = await orpc.posts.getStats({ id });
|
|
92
|
+
void captureClientEvent("post_stats_viewed", { post_id: id });
|
|
93
|
+
return result;
|
|
94
|
+
}
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
Preserve the `captureClientEvent` call where sibling procedures in that file have one. Analytics calls live in the action layer, not the router — that is the whole reason the layer exists.
|
|
98
|
+
|
|
99
|
+
## Step 4 — the test
|
|
100
|
+
|
|
101
|
+
`src/lib/orpc/router.test.ts` mounts the real router behind a real `RPCHandler` and drives it through a real `RPCLink`, mocking only the business-logic layer. Add a case alongside the existing ones:
|
|
102
|
+
|
|
103
|
+
```ts
|
|
104
|
+
it("returns stats for a post", async () => {
|
|
105
|
+
const client = makeTestClient();
|
|
106
|
+
const result = await client.posts.getStats({ id: "post-1" });
|
|
107
|
+
expect(result.views).toBe("42");
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it("rejects an empty post id", async () => {
|
|
111
|
+
const client = makeTestClient();
|
|
112
|
+
await expect(client.posts.getStats({ id: "" })).rejects.toThrow();
|
|
113
|
+
});
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
Run `bun test` (never `bun test --isolate` alone — AGENTS.md requires the isolate flag via `bun run test`).
|
|
117
|
+
|
|
118
|
+
## Two things that will bite you
|
|
119
|
+
|
|
120
|
+
### The RPC URL must be absolute
|
|
121
|
+
|
|
122
|
+
oRPC's `StandardRPCLink` does `new URL(baseUrl)`, which throws on a relative path. `resolveRpcUrl()` in `src/lib/orpc/client.ts` converts `/api/rpc` to an absolute URL using `window.location.origin`. It is a **function**, not a string constant, because `RPCLink.url` only invokes it when a request is actually issued — which is what makes touching `window` safe during SSR. Do not "simplify" it to a constant; the failure appears only at runtime, while type-checking passes.
|
|
123
|
+
|
|
124
|
+
### The catch-all route segment is required
|
|
125
|
+
|
|
126
|
+
The client addresses procedures as `/api/rpc/posts/list`. A route at `src/app/api/rpc/route.ts` matches only the bare `/api/rpc`, so every real call 404s into the not-found page. The `[...path]` segment is what makes `/api/rpc/...` resolve.
|
|
127
|
+
|
|
128
|
+
## Dates survive on their own
|
|
129
|
+
|
|
130
|
+
oRPC's Standard RPC serializer handles `Date`, `BigInt`, `Map`, `Set`, `URL`, `RegExp`, `NaN` and `undefined` natively via type tags in the response `meta` array. `superjson` was removed and is not needed. `PostWithReactions.createdAt` arrives at the client as a real `Date` — do not re-parse it, and do not add a serialization library.
|
|
131
|
+
|
|
132
|
+
## Service Worker coupling
|
|
133
|
+
|
|
134
|
+
`public/sw.js` intercepts `/api/rpc/` for the offline feed. `extractPostsFromResponse()` parses the `{ json, meta }` envelope and stores `createdAt`/`updatedAt` as ISO strings. If you add a procedure whose response the feed depends on, check whether the extractor needs to know about it.
|
|
135
|
+
|
|
136
|
+
## Verify
|
|
137
|
+
|
|
138
|
+
```bash
|
|
139
|
+
bun run test # full suite, isolated
|
|
140
|
+
bun run lint
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
Then, if the procedure is reachable from a screen, run the app and click through the actual flow. A procedure that type-checks and passes its unit test can still 404 at runtime for the two reasons above.
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: db-migration
|
|
3
|
+
description: Add or change a Postgres table, column or index in DS-CS with Drizzle, including the generated migration, the drizzle-zod schemas, and applying it. Use when adding a table, adding a column to posts/stories, changing an index, or when "column does not exist" appears at runtime. Not for reading data — that is the oRPC/REST layer.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Database migrations
|
|
7
|
+
|
|
8
|
+
## The layout
|
|
9
|
+
|
|
10
|
+
| Path | Role |
|
|
11
|
+
|---|---|
|
|
12
|
+
| `src/lib/db/schema/*.ts` | Table definitions — one file per table |
|
|
13
|
+
| `src/lib/db/schema/index.ts` | Barrel export |
|
|
14
|
+
| `src/lib/db/migrations/` | Generated SQL, committed |
|
|
15
|
+
| `src/lib/db/migrate.ts` | Runtime migrator, used by the container on boot |
|
|
16
|
+
| `drizzle.config.ts` | Points drizzle-kit at `schema` and `migrations` |
|
|
17
|
+
|
|
18
|
+
Tables use `pgTable` with a `text` primary key (UUIDs are generated in application code, not by a database default), `jsonb` for structured columns, and a drizzle-zod schema exported alongside each one.
|
|
19
|
+
|
|
20
|
+
## Commands
|
|
21
|
+
|
|
22
|
+
```bash
|
|
23
|
+
bun run db:generate # drizzle-kit generate → writes SQL into src/lib/db/migrations
|
|
24
|
+
bun run db:migrate # apply pending migrations
|
|
25
|
+
bun run db:push # push schema directly + seed (development shortcut)
|
|
26
|
+
bun run db:check # verify migration history is consistent
|
|
27
|
+
bun run db:studio # browse the database
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
`db:push` is a development shortcut that skips the migration files. **Never** use it as the way to ship a schema change — the container runs `db:migrate` on boot, so a change that exists only in the live database is a change that vanishes on the next deploy.
|
|
31
|
+
|
|
32
|
+
## Adding a table
|
|
33
|
+
|
|
34
|
+
1. Create `src/lib/db/schema/<name>.ts`:
|
|
35
|
+
|
|
36
|
+
```ts
|
|
37
|
+
import { index, jsonb, pgTable, text, timestamp } from "drizzle-orm/pg-core";
|
|
38
|
+
import { createInsertSchema, createSelectSchema } from "drizzle-zod";
|
|
39
|
+
|
|
40
|
+
import { toZodV4SchemaTyped } from "@/lib/zod-utils";
|
|
41
|
+
|
|
42
|
+
export const bookmarks = pgTable(
|
|
43
|
+
"bookmarks",
|
|
44
|
+
{
|
|
45
|
+
id: text("id").unique().primaryKey(),
|
|
46
|
+
postId: text("post_id").notNull(),
|
|
47
|
+
note: text("note"),
|
|
48
|
+
tags: jsonb("tags"),
|
|
49
|
+
createdAt: timestamp("created_at").$defaultFn(() => new Date()),
|
|
50
|
+
updatedAt: timestamp("updated_at")
|
|
51
|
+
.$defaultFn(() => new Date())
|
|
52
|
+
.$onUpdate(() => new Date()),
|
|
53
|
+
},
|
|
54
|
+
(table) => {
|
|
55
|
+
return {
|
|
56
|
+
bookmarksPostIdIdx: index("bookmarks_post_id_idx").on(table.postId),
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
);
|
|
60
|
+
|
|
61
|
+
export const selectBookmarksSchema = toZodV4SchemaTyped(createSelectSchema(bookmarks));
|
|
62
|
+
|
|
63
|
+
// Use pick() instead of omit() to avoid Zod v4 compatibility issues with createErrorSchema
|
|
64
|
+
export const insertBookmarksSchema = toZodV4SchemaTyped(
|
|
65
|
+
createInsertSchema(bookmarks).pick({ postId: true, note: true, tags: true })
|
|
66
|
+
);
|
|
67
|
+
|
|
68
|
+
export const patchBookmarksSchema = insertBookmarksSchema.partial();
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
2. Export it from `src/lib/db/schema/index.ts`.
|
|
72
|
+
3. `bun run db:generate`, then review the generated SQL before applying.
|
|
73
|
+
4. `bun run db:migrate`.
|
|
74
|
+
|
|
75
|
+
## Adding a column
|
|
76
|
+
|
|
77
|
+
Edit the table in place, then `bun run db:generate`. drizzle-kit asks whether the change is safe; the answer matters:
|
|
78
|
+
|
|
79
|
+
- **Adding a column with a default or nullable** — safe, generates `ADD COLUMN`.
|
|
80
|
+
- **Adding a NOT NULL column without a default to a table that already has rows** — drizzle-kit will warn. Either add a default, or add it as nullable and backfill in a second migration. One migration that both adds and backfills leaves no window where the column is missing, but it also locks the table for the duration; on `posts` and `stories`, prefer two migrations.
|
|
81
|
+
|
|
82
|
+
Indexes are declared in the table's third argument with an explicit name (`postsCreatedAtIdx` → `posts_created_at_idx`). Name them consistently.
|
|
83
|
+
|
|
84
|
+
## Conventions that are already decided
|
|
85
|
+
|
|
86
|
+
- `id` is `text`, unique, primary key — generated in application code.
|
|
87
|
+
- Timestamps use `$defaultFn(() => new Date())` and `$onUpdate(() => new Date())`.
|
|
88
|
+
- `views` and `likes` are stored as `text` with a numeric string default, not `integer`. This is deliberate: they are display counters, and keeping them out of the numeric range avoids contention on hot rows. Read them with `Number(...)` and write them back as strings.
|
|
89
|
+
- Enum-ish columns are `text({ enum: [...] })`, not a Postgres enum type.
|
|
90
|
+
- Structured data is `jsonb`. It is not validated at the database level — the drizzle-zod schemas and the oRPC input schemas are where validation happens.
|
|
91
|
+
|
|
92
|
+
## Verify
|
|
93
|
+
|
|
94
|
+
```bash
|
|
95
|
+
bun run db:check
|
|
96
|
+
bun run db:migrate
|
|
97
|
+
bun run test
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
Confirm the migration folder contains the new SQL and that it is committed. A migration that exists only on your machine reproduces fine locally and fails for everyone else.
|
|
101
|
+
|
|
102
|
+
## Deploying
|
|
103
|
+
|
|
104
|
+
The container runs `bun run db:migrate` before serving. For Vercel Fluid, the build script runs `db:migrate` as well. Either way the migration files are what ships — if they are not committed, deploys silently skip the change.
|