@nurix/etna 0.19.82 → 0.19.84
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/bin/etna.js +99 -7
- package/data/catalog/index.json +20 -0
- package/data/catalog/rules/changelog.md +25 -0
- package/data/catalog/rules/cloudflare.md +180 -0
- package/data/catalog/rules/code-eval-batcher.md +67 -0
- package/data/catalog/rules/database.md +34 -0
- package/data/catalog/rules/frontend.md +40 -0
- package/data/catalog/rules/general.md +73 -0
- package/data/catalog/rules/kb.md +6 -0
- package/data/catalog/rules/nomenclature.md +30 -0
- package/data/catalog/rules/pnpm.md +57 -0
- package/data/catalog/rules/react-native.md +120 -0
- package/data/catalog/rules/rust-desktop.md +109 -0
- package/data/catalog/rules/temporal.md +78 -0
- package/data/catalog/rules/testing.md +64 -0
- package/data/catalog/skills/nustack/SKILL.md +15 -0
- package/data/catalog/skills/nustack/references/cli-design.md +86 -0
- package/data/catalog/skills/nustack/references/monorepo-master-list.md +52 -0
- package/data/catalog/skills/nustack/references/monorepo-setup.md +98 -0
- package/data/catalog/skills/nustack/references/publishing.md +132 -0
- package/data/harness-index.json +1 -1
- package/lib/codex/skills.mjs +5 -2
- package/lib/install/materialize.mjs +31 -1
- package/package.json +44 -44
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# Nomenclature
|
|
2
|
+
|
|
3
|
+
Naming and casing for code — identifiers, files, folders. The rationale throughout is consistency: one concept, one name, one casing shape, in every repo that loads this rule.
|
|
4
|
+
|
|
5
|
+
Names state intent in full words — clarity over brevity; no abbreviations unless universally understood (`id`, `url` ✅ · `cfg`, `btn`, `idx` ❌).
|
|
6
|
+
|
|
7
|
+
## Casing
|
|
8
|
+
|
|
9
|
+
| Casing | Used for | Example |
|
|
10
|
+
| --- | --- | --- |
|
|
11
|
+
| `camelCase` | variables, functions, methods, properties; a module file exporting no component | `calculateTotal`, `sessionClient.ts` |
|
|
12
|
+
| `PascalCase` | classes, type definitions, components; the file exporting one | `PaymentProcessor`, `AppsListPage.tsx` |
|
|
13
|
+
| `UPPER_SNAKE_CASE` | constants, environment variables | `MAX_RETRY_COUNT` |
|
|
14
|
+
| `kebab-case` | URLs, CSS class names, HTML IDs, multi-word folders | `user-profile`, `workspace-provisioning/` |
|
|
15
|
+
| `snake_case` | database tables, columns, indexes | `created_at` |
|
|
16
|
+
|
|
17
|
+
**A file exporting one component carries that component's name exactly**, so an import names the file it came from; a single-word folder is plain lowercase (`lib`, `surfaces`). ❌ Never `snake_case` a file or folder — that casing belongs to the database.
|
|
18
|
+
|
|
19
|
+
## Identifier shape
|
|
20
|
+
|
|
21
|
+
| Identifier | Shape | Example |
|
|
22
|
+
| --- | --- | --- |
|
|
23
|
+
| Variable | noun phrase | `paymentStatus` |
|
|
24
|
+
| Function / method | verb phrase naming the action | `validateInput` |
|
|
25
|
+
| Boolean | `is` / `has` / `can` / `should` prefix — **never** a bare adjective | `hasPermission` |
|
|
26
|
+
| Array / collection | plural noun | `errorMessages` |
|
|
27
|
+
| Class / component | singular noun phrase | `RequestHandler` |
|
|
28
|
+
| Event handler | `handle` or `on` prefix | `handleSubmit`, `onPageLoad` |
|
|
29
|
+
| Interface | `I` prefix acceptable; type aliases stay plain | `IConfigOptions`, `AuthResponse` |
|
|
30
|
+
| Async function | verb phrase; `Async` suffix acceptable | `loadConfigAsync` |
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# pnpm — `@nurix/*` Workspaces: naming, boundaries, and consuming
|
|
2
|
+
|
|
3
|
+
How a `@nurix/*` repo is **named**, **structured**, and how its packages are **consumed** — every repo takes the workspace shape, single-package frontends included. Publishing and CLI design live in the `nustack` skill, not here.
|
|
4
|
+
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
## 1. Package naming
|
|
8
|
+
|
|
9
|
+
Every workspace package is scoped **`@nurix/`**; the dot vs. hyphen after the scope is semantic, not cosmetic:
|
|
10
|
+
|
|
11
|
+
| Form | Meaning | Examples |
|
|
12
|
+
| --- | --- | --- |
|
|
13
|
+
| `@nurix/workflows-sdk` (hyphen) | **Published, externally-destined** contract | `@nurix/workflows-sdk` |
|
|
14
|
+
| `@nurix/workflows.<name>` (dot) | **Internal** to the product — `private: true`, refactor freely. The default for a new package | `@nurix/workflows.agent-core`, `.worker`, `.playground` |
|
|
15
|
+
| `@nurix/<name>` (plain) | Cross-repo shared lib owned **elsewhere** — never renamed | `@nurix/contracts`, `@nurix/components` |
|
|
16
|
+
|
|
17
|
+
- `<name>` is always **kebab-case**.
|
|
18
|
+
- The dot is **not** a path separator — subpath exports resolve normally (`@nurix/workflows.agent-core/nodes`).
|
|
19
|
+
- The dot tracks "internal product package," not the `private` flag: `@nurix/workflows.secrets` is `private: false` (extraction-bound) yet dotted. `-sdk` is the only clean published form.
|
|
20
|
+
|
|
21
|
+
---
|
|
22
|
+
|
|
23
|
+
## 2. Monorepo & package boundaries
|
|
24
|
+
|
|
25
|
+
- **One `tsconfig.base.json` at the repo root owns TS policy** — the strict set, `target`, and everything uniform across packages (here, `module`/`moduleResolution`). Each package's `tsconfig.json` `extends` the base and adds only its coordinates (`rootDir`/`outDir`/`include`) and genuine environment overrides (`lib`/`types`/`jsx`; `module`/`moduleResolution` only when the package's module system truly differs). **Never** re-declare or weaken a policy option locally. One exception: a framework that owns its own base (an Astro app on `astro/tsconfigs/strict`).
|
|
26
|
+
- **Internal packages are source-first.** A `private: true` package exposes its API via `exports` pointing at source (`"types"`/`"default"` → `./src/index.ts`) — no build step, and no bare `main`/`module` on new packages. One typed subpath per public surface; consumers import the subpath, **never** a deep relative path into another package's `src/`. `types` comes **first** in every subpath block — resolvers take the first matching condition, so a `types` listed after `default` is never reached.
|
|
27
|
+
- **Crossing the publish boundary is the `nustack` skill** — `private: false`, the published manifest, the pipeline.
|
|
28
|
+
- **Single-source shared dep versions with pnpm `catalog:`** — `typescript`, `zod`, `vitest`, `react`, type packages — so every package resolves the same copy and its types stay mutually assignable.
|
|
29
|
+
- **TS project references only when it hurts** — adopt `references` once the typecheck/build graph is slow enough to feel; until then, `pnpm -r typecheck`.
|
|
30
|
+
|
|
31
|
+
### The catalog has no garbage collector
|
|
32
|
+
|
|
33
|
+
- **Removing the last consumer of a cataloged package removes its catalog entry in the same change** — `pnpm remove` rewrites the manifest and lockfile but **never** `pnpm-workspace.yaml`, and nothing downstream flags the orphan: no install fails, no lint fires.
|
|
34
|
+
- The check is mechanical: an entry no workspace manifest names under any dependency field is orphaned; one absent from `pnpm-lock.yaml` is an orphan with certainty.
|
|
35
|
+
- **A cataloged package is `catalog:` in every manifest** — a literal range (`"zod": "^4.2.1"`) resolves identically today and silently stops tracking the catalog the moment it moves.
|
|
36
|
+
|
|
37
|
+
### The workspace root is a hard boundary
|
|
38
|
+
|
|
39
|
+
- **A dependency spec never points outside the folder holding `pnpm-workspace.yaml`** — `devDependencies` included. Inside the root, `workspace:*` between `packages/*` is the normal, correct thing; outside it, nothing — no `file:../…`, no `link:`, no absolute `file:`. A sibling checkout is another repo, and the registry is the only supported way to consume one — a cross-root spec bakes machine-local paths into the lockfile and the published tarball, so `install --frozen-lockfile` fails on any other machine.
|
|
40
|
+
- **A package unreachable from the registry is an auth or publishing bug — fix it there, never with a local path.** Diagnosis order: the scope's registry (§3) → the token → whether it was actually published.
|
|
41
|
+
|
|
42
|
+
---
|
|
43
|
+
|
|
44
|
+
## 3. Consuming `@nurix/*` packages
|
|
45
|
+
|
|
46
|
+
`@nurix/*` packages publish to npm with **`restricted`** access — installing needs read rights to the `nurix` org.
|
|
47
|
+
|
|
48
|
+
- **Registry** — point the scope at npmjs, where the packages are published. A restricted read answers `404`, not `401`, so a mispaired registry is indistinguishable from "never published" — on an `@nurix/*` 404, check the registry pairing **first**, before auth and before assuming the package is missing.
|
|
49
|
+
- **Auth** — `npm login` once (the npm user must be a `nurix` member), or a read token in `.npmrc` (the only CI option):
|
|
50
|
+
```ini
|
|
51
|
+
@nurix:registry=https://registry.npmjs.org/
|
|
52
|
+
//registry.npmjs.org/:_authToken=${NPM_READ_TOKEN}
|
|
53
|
+
```
|
|
54
|
+
The token needs only `Read` on the `nurix` scope. A `403` on install means auth failed — check `npm whoami` (local) or the `NPM_READ_TOKEN` secret (CI). Attempt the install directly — the token authenticates ambiently; don't pre-empt or second-guess it. If it fails, flag it to the user — **never** reach for alternatives, a substitute package, or `npm login` swaps.
|
|
55
|
+
- **Import by subpath, never the barrel** — subpaths tree-shake reliably; the available subpaths are the package's `exports` keys. `import { Button } from "@nurix/components/button";`
|
|
56
|
+
- **Styles** — import a CSS-shipping package's stylesheet **once** at the app entry: `import "@nurix/components/styles.css";`. Unstyled components are almost always a missing styles import. The shipped stylesheet is precompiled — no Tailwind `@source` directive unless consuming source via a workspace link.
|
|
57
|
+
- **CLIs** ship a `bin` — run directly: `npx @nurix/etna@latest` (or `npm install -g`).
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
# React Native + Expo
|
|
2
|
+
|
|
3
|
+
How mobile apps are built — distilled from a hand-crafted reference app, the gold standard for *style*. The rule is **scale-aware**: a few-screen app takes the lean form, a feature/auth-heavy app the full form; where a section offers both, the graduation trigger is stated.
|
|
4
|
+
|
|
5
|
+
## 1. Stack & packages
|
|
6
|
+
|
|
7
|
+
- **Expo SDK** managed workflow, config-as-code (`app.config.ts`, **never** `app.json`), `expo-router` navigation.
|
|
8
|
+
- New Architecture **on**; off **only** for a legacy native module that breaks under it — document why at the top of `app.config.ts`.
|
|
9
|
+
- `react` / `react-dom` versions **must** match. Install native/Expo modules with `npx expo install <pkg>` — a bare add picks a version the SDK may not support.
|
|
10
|
+
|
|
11
|
+
| Need | Use | Don't use |
|
|
12
|
+
| --- | --- | --- |
|
|
13
|
+
| HTTP | native `fetch` wrapped in a `request<T>()` util | `axios` |
|
|
14
|
+
| Navigation | `expo-router` (file-based) | bare React Navigation at the root |
|
|
15
|
+
| Storage (sensitive) | `expo-secure-store` | `AsyncStorage` |
|
|
16
|
+
| Storage (prefs/cache) | `@react-native-async-storage/async-storage` | `expo-secure-store` |
|
|
17
|
+
| State (non-trivial) | `@reduxjs/toolkit` + `react-redux` | `zustand`, `jotai` |
|
|
18
|
+
| Styling | `StyleSheet.create` + token object | `nativewind`, `tamagui`, styled-components |
|
|
19
|
+
| Toasts | `react-native-toast-message` | custom |
|
|
20
|
+
| Haptics | `expo-haptics` behind one dispatcher | scattered call sites |
|
|
21
|
+
|
|
22
|
+
## 2. Structure & naming
|
|
23
|
+
|
|
24
|
+
- **Path alias `@/*` → `./src/*`** (`tsconfig.json`). Import via `@/...`, **never** deep-relative (`../../..`); sibling/child relatives are fine.
|
|
25
|
+
- Every file opens with a path + one-line purpose comment; one line above each function.
|
|
26
|
+
- **No barrel/index re-export files** — import the source module directly.
|
|
27
|
+
- Components & screens `PascalCase.tsx`; hooks `useXxx.ts`; services/utils/reducers/types `camelCase.ts`; route files follow `expo-router` (`index.tsx`, `[id].tsx`, `_layout.tsx`).
|
|
28
|
+
|
|
29
|
+
**Layout** — `app/` is routes only (§3); everything else under `src/`:
|
|
30
|
+
|
|
31
|
+
```
|
|
32
|
+
app/ # expo-router routes ONLY
|
|
33
|
+
src/
|
|
34
|
+
components/ # presentational primitives + shared UI
|
|
35
|
+
hooks/
|
|
36
|
+
services/ # api client, auth storage, background tasks, notifications
|
|
37
|
+
state/ or reducers/ + store/ # §4 — scale decides
|
|
38
|
+
types/ # shared TS contracts
|
|
39
|
+
utils/ # pure helpers
|
|
40
|
+
constants/ # Colors.ts, routes.ts, toast_config.tsx
|
|
41
|
+
navigation/ # only if you outgrow Stack/Tabs
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
> **Lean form:** a handful of screens groups by domain instead — e.g. `src/{data,player,state,ui}`, `app/` still routes-only. Graduate to the by-type layout past >~3 cross-cutting concerns (auth + tabs + notifications + ...).
|
|
45
|
+
|
|
46
|
+
## 3. Expo Router & navigation
|
|
47
|
+
|
|
48
|
+
- A route file may hold its own data/business logic — the strict container/presentational split is not required, but pure UI primitives (`src/components/`) stay pure.
|
|
49
|
+
- `experiments.typedRoutes: true`. Navigate with `router.push()` / `router.replace()` (`replace` for auth/redirect transitions); `<Link>` **only** for static in-content links; params via `useLocalSearchParams()`.
|
|
50
|
+
- Route groups `(auth)` / `(tabs)` partition without affecting the URL; an `index.tsx` may be pure redirect logic, not a screen.
|
|
51
|
+
- **Root `_layout.tsx` is the provider tree**, outside-in, only what the app needs: `ErrorBoundary → Redux Provider → HydrationGate → GestureHandlerRootView → SafeAreaProvider → StatusBar → <Stack/Tabs> → Toast`. Lean form: `StatusBar → Stack` plus a one-time `useEffect` bootstrap.
|
|
52
|
+
- Header styling via `screenOptions` on the `Stack`; per-screen title via `<Stack.Screen options={{ title }}>`. `createBottomTabNavigator` inside `(tabs)/_layout.tsx` is acceptable over router `Tabs` when a custom header/drawer needs fine layout control.
|
|
53
|
+
|
|
54
|
+
## 4. State
|
|
55
|
+
|
|
56
|
+
- **Trivial** (no auth, few screens) — plain TS modules under `src/state/`: one concern per module, async get/set functions, `AsyncStorage` persistence with swallowed try/catch, optional module-level cache.
|
|
57
|
+
- **Non-trivial** (auth, preloaded config, cross-screen data) — **Redux Toolkit**: slices in `src/reducers/`, store in `src/store/`, one root reducer.
|
|
58
|
+
- Persist with listener middleware (debounced ~300ms), **not** `redux-persist`.
|
|
59
|
+
- Dual storage: tokens → `expo-secure-store`; UI prefs → `AsyncStorage`.
|
|
60
|
+
- **Hydrate before first render** — async hydrate thunks behind a `HydrationGate` spinner, so no flash of unauthenticated UI.
|
|
61
|
+
- Store absolute expiries (`accessExpiresAt`), computed at login from the `expiresIn` the server returns.
|
|
62
|
+
|
|
63
|
+
## 5. Data fetching / networking
|
|
64
|
+
|
|
65
|
+
- Native `fetch`, wrapped once: `request<T>(method, endpoint, body?, opts?)` in `src/services/api.ts`, plus a `useRequest<T>()` hook returning `{ loading, response, error, makeRequest }`.
|
|
66
|
+
- Base URL and auth token are read **at call time** (from Redux state or the settings module), never baked into a client instance; inject `Authorization: Bearer <token>` only when `authorized: true`.
|
|
67
|
+
- Per-call `onSuccess` / `onFail` callbacks, no global interceptor; failures surface as toasts or inline error UI.
|
|
68
|
+
- **Every fetched surface renders loading, empty, and error states** (error with a Retry affordance). Cache in-memory at the data module (lean) or in Redux slices (full).
|
|
69
|
+
- A `RequestMethod` union and a generic `ApiResponse<T>`; endpoint-specific wrappers live next to their screen (`app/<feature>/api.ts`).
|
|
70
|
+
|
|
71
|
+
## 6. Styling & theming
|
|
72
|
+
|
|
73
|
+
- `StyleSheet.create` for static styles, inline only for dynamic theme values. **No** utility-CSS or styled-component libs (§1 table).
|
|
74
|
+
- Color tokens live in one place (`src/constants/Colors.ts`) — components reference tokens, **never** raw hex.
|
|
75
|
+
- **Theming is a hook, not a Context provider.** `useTheme()` returns `{ colors, colorScheme, isDark }`, resolving user preference → device `useColorScheme()` → light fallback. The preference lives in state (`customize.theme`: `"light" | "dark" | "system"`), so theme survives restart and there is no provider to thread.
|
|
76
|
+
|
|
77
|
+
> **Lean form:** a flat single-mode token object until dark mode is a requirement; then adopt the light/dark `Colors` + `useTheme()` shape.
|
|
78
|
+
|
|
79
|
+
## 7. Native modules & permissions
|
|
80
|
+
|
|
81
|
+
- **Wrap each `expo-*` module behind a typed helper** — never raw calls at call sites. Canonical: one `triggerHaptic(type)` dispatcher with named exports (`triggerSuccessHaptic`, `triggerTabChangeHaptic`, ...) over `expo-haptics`.
|
|
82
|
+
- Platform forks via filename suffixes (`Foo.native.tsx` / `Foo.web.tsx` / `Foo.tsx`); `Platform.OS` checks **only** for small inline branches.
|
|
83
|
+
- Android/iOS permissions and usage strings are declared in `app.config.ts`; request at point-of-use with a clear pre-prompt where the platform allows.
|
|
84
|
+
- Background work (location, audio) runs through a registered task (`expo-task-manager` / a playback service), registered in the app entry **before the router mounts** — it must survive the UI being unmounted.
|
|
85
|
+
|
|
86
|
+
## 8. Reusable primitives
|
|
87
|
+
|
|
88
|
+
- `Themed{View,Text,Button}` are the base layer: extend the native props, add `lightColor?` / `darkColor?`; `ThemedText` takes `type?: "default" | "title" | "subtitle" | "link"`; `ThemedButton` takes `variant?: "contained" | "outlined" | "text"`, `size?`, `fullWidth?`.
|
|
89
|
+
- `EdgeToEdgeScreen` / `EdgeToEdgeTabScreen` / `EdgeToEdgeTabBar` own safe-area insets — screens **never** compute insets inline or ad hoc.
|
|
90
|
+
- Loading/feedback primitives (`DotLoader`, `CircularProgress`, `PullToRefreshHint`) are shared, never re-implemented per screen.
|
|
91
|
+
|
|
92
|
+
## 9. TypeScript
|
|
93
|
+
|
|
94
|
+
- `interface` for external contracts (API shapes, component props); `type` for unions and aliases (`type RequestMethod = "get" | ...`).
|
|
95
|
+
- Shared domain types in `src/types/`; prop types extend native ones (`TextProps & { ... }`).
|
|
96
|
+
- **Never weaken `strict` or `noUncheckedIndexedAccess`** — extend `expo/tsconfig.base`, don't relax it. Generic `request<T>` / `useRequest<T>`; no implicit `any` on external data.
|
|
97
|
+
|
|
98
|
+
## 10. Config & environment
|
|
99
|
+
|
|
100
|
+
- `app.config.ts` is the single source: scheme, deep-link intent filters, permissions, plugins, `userInterfaceStyle: "automatic"`, typed routes (§3), splash/icon.
|
|
101
|
+
- Client-readable env via `EXPO_PUBLIC_*` **only**, read through `expo-constants` or `process.env`. **Never commit `.env`** — ship `.env.example`; secrets/keystore creds stay out of git.
|
|
102
|
+
- Build profiles in `eas.json` (`development` / `preview` / `production`); local config plugins (`./plugins/withXxx.js`) for native edits the SDK doesn't expose.
|
|
103
|
+
|
|
104
|
+
## 11. Errors, loading & UX
|
|
105
|
+
|
|
106
|
+
- **An `ErrorBoundary` class component wraps the whole app.** On crash: log, clear *session* state but **preserve user preferences**, show an apology + recover affordance.
|
|
107
|
+
- Toasts via `react-native-toast-message` with a custom `toast_config.tsx` (success/error/warning variants, `topOffset` below the header); fired inline with `Toast.show({ type, text1, text2 })`.
|
|
108
|
+
- Hide the splash only after fonts load and state hydrates (`expo-splash-screen`).
|
|
109
|
+
- Lists: `FlatList` typed with `ListRenderItemInfo<T>`, `ListEmptyComponent` for empty, `onEndReached` + pagination for infinite scroll, `refreshing`/`onRefresh` for pull-to-refresh.
|
|
110
|
+
|
|
111
|
+
## 12. Web target (`expo start --web`)
|
|
112
|
+
|
|
113
|
+
- Web needs `react-dom`, `react-native-web`, `@expo/metro-runtime`, and a `"web": "expo start --web"` script.
|
|
114
|
+
- A blank page with a bundle resolve failure means `react-native-track-player` is missing its web peer `shaka-player`.
|
|
115
|
+
- Native-only features (background audio, lock-screen controls, Android Auto) have no web equivalent — guard them behind `Platform.OS` so the web build renders, and treat web as a layout/navigation preview, **never** a playback/Auto test surface.
|
|
116
|
+
|
|
117
|
+
## 13. Formatting
|
|
118
|
+
|
|
119
|
+
- Prettier: `tabWidth: 4`, double quotes, `bracketSameLine: true`, `printWidth: 120`, trailing commas.
|
|
120
|
+
- No commented-out code; comments explain *why*, not *what*.
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
# Rust Desktop Apps (Tauri 2)
|
|
2
|
+
|
|
3
|
+
How native desktop apps are built: a **Rust core + web-frontend** shell — the cargo/JS dual workspace, the Rust↔TS contract seam, native-capability handling, and signed distribution. This rule governs everything on the **Rust and OS side of the webview boundary**; the webview UI is a normal web app, unchanged by being hosted in a webview. Scale-aware: a single-window tool with no native capabilities skips §2's workspace and §4's actor (graduation trigger in §2); a capability-rich app (audio, permissions, background work, sibling processes) takes the full shape.
|
|
4
|
+
|
|
5
|
+
## 1. Stack & frame
|
|
6
|
+
|
|
7
|
+
- **Tauri 2** — Rust core + the OS webview, one native binary. **Never Electron** (bundles Chromium + Node, no Rust core, ~10× the footprint); **never** a Rust-native GUI toolkit (`egui` / `iced` / `Slint`) for anything with real UI.
|
|
8
|
+
- **The frontend builds to static assets** that `frontendDist` points at and reaches Rust only through Tauri **commands** (request/response) and **events** (push). **No Node in production** — anything Node-only (`fs`, `spawn`, native modules) is a Rust command.
|
|
9
|
+
- **Dual workspace:** a cargo workspace for the Rust crates, a JS workspace for the webview app(s). Detect the JS package manager from the lockfile; never hardcode one.
|
|
10
|
+
- **Run the Tauri CLI via the JS devDependency** (`@tauri-apps/cli`, invoked as `pnpm tauri …`), **never** a global `cargo tauri` — the global drifts from the project's pinned CLI version.
|
|
11
|
+
|
|
12
|
+
| Need | Use | Not |
|
|
13
|
+
| --- | --- | --- |
|
|
14
|
+
| Rust→TS types | `ts-rs` derive → generated `.ts` | hand-written duplicate types, `serde-reflection` |
|
|
15
|
+
| Async runtime | `tokio` (`features=["full"]` at the app edge only) | async-std, smol |
|
|
16
|
+
| HTTP client | `reqwest` (`default-features=false`, `rustls-tls`) | system openssl, hyper direct |
|
|
17
|
+
| Errors | `thiserror` (library crates) · `anyhow` (the bin/app crate) | `Box<dyn Error>`, panicking on recoverable paths |
|
|
18
|
+
| Observability | `tracing` + `tracing-subscriber` | `log` + `env_logger`, `println!` |
|
|
19
|
+
| Localhost IPC to sibling processes | `axum` / `tokio-tungstenite` on a fixed port | ad-hoc TCP framing |
|
|
20
|
+
| JWT / auth | `jsonwebtoken` | rolling your own |
|
|
21
|
+
| Ids across process edges | `uuid` / `nanoid` | incrementing integers |
|
|
22
|
+
|
|
23
|
+
## 2. Workspace & crate structure
|
|
24
|
+
|
|
25
|
+
- **One crate per bounded context**, flat under `crates/`; the workspace root globs `members = ["crates/*"]` so adding a crate needs no root edit. Shared versions live in `[workspace.dependencies]`; members opt in with `<dep>.workspace = true`.
|
|
26
|
+
- **Exactly one binary crate — `<slug>-app` — is the composition root and the *only* crate allowed to touch Tauri, the OS/FFI, and the window.** Every other crate is a framework-agnostic library, unit-testable without Tauri. A native call outside `<slug>-app` is a layering break.
|
|
27
|
+
- **A dedicated `<slug>-contracts` crate owns every type that crosses the webview or a process boundary** (§3). No other crate defines a wire/command/event type.
|
|
28
|
+
- Crates are **kebab-case, prefixed with the app slug** (`sidekick-audio`, `sidekick-state`); the bin is `<slug>-app`.
|
|
29
|
+
|
|
30
|
+
> A single-window utility with no bounded contexts is **one crate** — the `-app` crate itself. Graduate to the workspace split at the second context (a background worker, a capture/engine unit, a sibling process).
|
|
31
|
+
|
|
32
|
+
## 3. Rust↔TS contract sync (the load-bearing seam)
|
|
33
|
+
|
|
34
|
+
The Rust type is the **single source of truth**; the TS type is generated from it.
|
|
35
|
+
|
|
36
|
+
- **`#[derive(TS)]` (`ts-rs`)** on every command argument, command return, and event payload in `<slug>-contracts`; an `export_bindings` test writes them into the webview package's `src/generated/`.
|
|
37
|
+
- **Generated files are committed and re-validated with a `zod` schema at the webview edge** — parse every inbound payload; never trust the boundary at runtime just because it typechecks.
|
|
38
|
+
- **CI runs a drift gate:** regenerate, then `git diff --exit-code` the generated dir — a nonzero diff means a Rust type changed without regeneration; the build fails.
|
|
39
|
+
|
|
40
|
+
```bash
|
|
41
|
+
# the two scripts every project wires:
|
|
42
|
+
contracts:gen → cargo test -p <slug>-contracts export_bindings # regenerate
|
|
43
|
+
contracts:check → git diff --exit-code packages/*/src/generated/ # CI drift gate
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
- **Never hand-edit a generated file, and never hand-author a parallel TS type** — two sources of truth and a red diff gate. Change the Rust type and regenerate.
|
|
47
|
+
|
|
48
|
+
## 4. Process model & state
|
|
49
|
+
|
|
50
|
+
- **Single-writer state actor.** Mutable app state lives behind one owning task; the rest of the app sends it typed `Command`s and consumes an `Event` stream. **No** `Arc<Mutex<AppState>>` threaded through command handlers — that shared-mutable race is exactly what the actor removes.
|
|
51
|
+
- **The webview is a pure projection.** Rust pushes state through a typed emitter (`feed_state`, `nav_state`, …); the UI renders it and sends intents back as commands. Never compute authoritative state in the webview.
|
|
52
|
+
- **`#[tauri::command]` functions stay thin** — validate args, forward to the actor, return. Business logic lives in the library crates, not in command handlers.
|
|
53
|
+
|
|
54
|
+
## 5. Native capability & permissions
|
|
55
|
+
|
|
56
|
+
- **Degrade, never panic, on a missing capability.** A missing model file, an ungranted permission, an absent device → a `tracing::warn!` and a disabled feature, **never** a crash. The app must start and run with zero secrets and zero optional assets present.
|
|
57
|
+
- **Heavy or licensed assets (ML models, large binaries) resolve from the bundle resource dir via an env var — never committed, never downloaded at runtime.** The crate takes a `PathBuf` and never touches the network; absent ⇒ that stage degrades per the rule above.
|
|
58
|
+
- **OS permission grants (macOS TCC, etc.) key to the *signed bundle identity* and cannot be granted programmatically.** A permission-gated path that isn't granted returns "unavailable" — it does not block startup — and its real exercise is an `#[ignore]`d hardware test (§8).
|
|
59
|
+
|
|
60
|
+
> **Agent guard:** when a capability is unavailable in dev/CI, **do not** stub a fake success, fabricate engine output, or add a runtime download to "make it work." Wire the degrade path and mark the real path `#[ignore]`. Silent fallback hides a broken build — fail visibly.
|
|
61
|
+
|
|
62
|
+
## 6. Build profiles & `target/` hygiene
|
|
63
|
+
|
|
64
|
+
Debug builds and per-crate **test** binaries accumulate; a workspace `target/` reaches tens of GB without discipline.
|
|
65
|
+
|
|
66
|
+
```toml
|
|
67
|
+
# workspace Cargo.toml
|
|
68
|
+
[profile.dev]
|
|
69
|
+
debug = "line-tables-only"
|
|
70
|
+
|
|
71
|
+
[profile.release]
|
|
72
|
+
opt-level = 3
|
|
73
|
+
lto = "thin"
|
|
74
|
+
codegen-units = 1
|
|
75
|
+
strip = true # the shipped binary carries no symbols
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
- **`debug = "line-tables-only"` on `profile.dev`** — full `debug = 2` bloats each of the dozens of per-crate test executables; line tables keep `file:line` panic backtraces while cutting binaries 40–60%. `profile.test` inherits it.
|
|
79
|
+
- **Cap `target/` with `cargo-sweep`, not periodic `cargo clean`** — `cargo sweep --maxsize <MB> -r .` (or `--time <days>`) evicts stale hashed artifacts while keeping the cache warm; cargo never garbage-collects old test binaries itself.
|
|
80
|
+
- **In CI, cache `target/` with `Swatinem/rust-cache`** (it prunes intermediates before caching). `target/` is always git-ignored.
|
|
81
|
+
|
|
82
|
+
**Pitfall:** `cargo build`/`cargo test` fails with `No such file or directory` on a path that doesn't exist — the repo was **moved or renamed with a warm `target/`**; absolute paths are baked into build-script outputs and `env!("CARGO_MANIFEST_DIR")` test binaries. Fix: one `cargo clean` (or `cargo sweep`) after relocating the repo root.
|
|
83
|
+
|
|
84
|
+
## 7. Signing, notarization & distribution
|
|
85
|
+
|
|
86
|
+
- **Distribute via Developer ID (direct-download `.dmg`/`.app`), not the App Store**, when the app needs capabilities the sandbox blocks (system audio, broad filesystem). Developer ID needs no provisioning profile.
|
|
87
|
+
- **Signing is opt-in and must never hard-fail an unsigned build.** No signing secrets present → an **unsigned** artifact; secrets present → sign → notarize → staple. Drive the build with `tauri-apps/tauri-action`.
|
|
88
|
+
- **A universal macOS binary needs *both* `aarch64-apple-darwin` and `x86_64-apple-darwin` targets installed** — `--target universal-apple-darwin` fails with only one. A local run uses just the host arch.
|
|
89
|
+
|
|
90
|
+
> **Agent guard (signing):** a signing or notarization failure is **stop-and-report** — never disable signing, never fabricate a certificate, never fall back to ad-hoc (`-`) signing to get a green build. Report the missing secret or identity. (Cf. the `NPM_PUBLISH_TOKEN` guard in the `nustack` skill.)
|
|
91
|
+
|
|
92
|
+
## 8. Testing
|
|
93
|
+
|
|
94
|
+
Framework: `cargo test`. This section is the desktop-specific shape.
|
|
95
|
+
|
|
96
|
+
- **Headless-by-default.** The whole non-UI, non-hardware path (state actor, commands, networking, business logic) is tested with **no window, no device, no models** — mock the external service, drive the actor directly. This is the tier CI runs.
|
|
97
|
+
- **Deterministic fixtures over live capture** — a committed input fixture drives the real state machines and asserts exact outputs; never gate a unit test on a live device.
|
|
98
|
+
- **Hardware / permission / model tests are `#[ignore]`d behind a feature or env var, run only on real hardware, and skip *honestly*** — print `skipping (model absent)` and return; **never** a fake pass. Non-default features (`--features real-*`) gate the heavy dependency so the default build stays light.
|
|
99
|
+
|
|
100
|
+
## 9. Config & environment
|
|
101
|
+
|
|
102
|
+
- **`.env.example` is the canonical env contract**; read keys via `std::env` at the app edge, never bake them into a client instance. Ship `.env.example`, **never** a real `.env`.
|
|
103
|
+
- **Bundle metadata + entitlements live beside the `-app` crate** (`tauri.conf.json` next to the bin), not at the repo root — a non-obvious location; tooling and agents point `tauri-action` at that dir.
|
|
104
|
+
- **A fixed cross-process port is a declared constant, not a config knob** when a sibling (extension, viewer) hard-codes it — a port env var the peer can't read is a footgun; drop it and document the constant.
|
|
105
|
+
|
|
106
|
+
## 10. Formatting & gates
|
|
107
|
+
|
|
108
|
+
- **`cargo fmt --check` and `cargo clippy -- -D warnings` are CI gates**, not suggestions — they run alongside the contract-drift gate (§3) and the frontend checks.
|
|
109
|
+
- The Rust `edition` and `rust-version` (MSRV) are pinned in `[workspace.package]`; bumping either is a deliberate change, never incidental.
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
# Temporal
|
|
2
|
+
|
|
3
|
+
Workflow and activity conventions for Temporal-backed services — the determinism boundary, file layout, registration, retry defaults, and starting workflows.
|
|
4
|
+
|
|
5
|
+
## Determinism
|
|
6
|
+
|
|
7
|
+
Temporal replays a workflow from its event history, so anything non-deterministic in workflow code diverges on replay and corrupts the run — the reason behind every row below. Activities are stateless, idempotent functions — retries re-run them.
|
|
8
|
+
|
|
9
|
+
| In workflow code | Do | Never |
|
|
10
|
+
| --- | --- | --- |
|
|
11
|
+
| I/O | call activities | direct DB/API calls |
|
|
12
|
+
| Imports | static | dynamic `import()` |
|
|
13
|
+
| Data crossing the workflow↔activity boundary | JSON-serializable | classes, functions |
|
|
14
|
+
| Time | `sleep()` with durations | absolute timestamps |
|
|
15
|
+
| Long runs | `continueAsNew` at ~10k history events | unbounded history |
|
|
16
|
+
| Child workflows | `await child.result()`, handle `ChildWorkflowFailure` | fire-and-forget |
|
|
17
|
+
|
|
18
|
+
## Layout
|
|
19
|
+
|
|
20
|
+
| Path | Holds |
|
|
21
|
+
| --- | --- |
|
|
22
|
+
| `app/workflows/<feature>/workflow.js` | the workflow definition |
|
|
23
|
+
| `app/workflows/<feature>/activities.js` | that feature's activities — plain exported `async` functions, one destructured-object arg |
|
|
24
|
+
| `temporal/config/workflowModules.js` · `activityModules.js` | registration (below) |
|
|
25
|
+
| `temporal/providers/` · `temporal/services/` · `temporal/worker.js` | shared infrastructure — `WorkflowStarter`, proxy utilities, worker init — edit sparingly |
|
|
26
|
+
|
|
27
|
+
## The worked example
|
|
28
|
+
|
|
29
|
+
Task queues are `<name>_${process.env.ENVIRONMENT}` everywhere — registration, child workflows, API starts. The `retry` block is the project default; keep it unless the feature demands otherwise.
|
|
30
|
+
|
|
31
|
+
```javascript
|
|
32
|
+
import { proxyActivities, startChild } from "@temporalio/workflow";
|
|
33
|
+
|
|
34
|
+
const { fetchData, pushData } = proxyActivities({
|
|
35
|
+
startToCloseTimeout: "30 minutes",
|
|
36
|
+
retry: {
|
|
37
|
+
initialInterval: "10s",
|
|
38
|
+
maximumInterval: "2m",
|
|
39
|
+
backoffCoefficient: 3.0,
|
|
40
|
+
maximumAttempts: 3,
|
|
41
|
+
},
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
export async function myWorkflow({ tenantId, appId }) {
|
|
45
|
+
const data = await fetchData({ tenantId, appId });
|
|
46
|
+
await pushData({ data });
|
|
47
|
+
|
|
48
|
+
const child = await startChild("childWorkflow", {
|
|
49
|
+
workflowId: `${tenantId}-child`,
|
|
50
|
+
taskQueue: `my_queue_${process.env.ENVIRONMENT}`,
|
|
51
|
+
args: [{ tenantId }],
|
|
52
|
+
});
|
|
53
|
+
await child.result();
|
|
54
|
+
|
|
55
|
+
return { status: 1, message: "Success", data };
|
|
56
|
+
}
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
## Registration
|
|
60
|
+
|
|
61
|
+
Every workflow registers in `temporal/config/workflowModules.js`; `activityDependencies` names every activity the workflow proxies:
|
|
62
|
+
|
|
63
|
+
```javascript
|
|
64
|
+
export const workflows = {
|
|
65
|
+
myWorkflow: {
|
|
66
|
+
path: new URL("../../app/workflows/feature/workflow.js", import.meta.url)
|
|
67
|
+
.pathname,
|
|
68
|
+
queue: `my_queue_${process.env.ENVIRONMENT}`,
|
|
69
|
+
activityDependencies: ["fetchData", "pushData"],
|
|
70
|
+
},
|
|
71
|
+
};
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
The feature's activities spread into `temporal/config/activityModules.js`: `import * as featureActivities from "../../app/workflows/feature/activities.js"`, then `export const activities = { ...featureActivities }`.
|
|
75
|
+
|
|
76
|
+
## Starting from the API
|
|
77
|
+
|
|
78
|
+
`startWorkflow("myWorkflow", { workflowId, taskQueue, args })` from `temporal/services/WorkflowStarter.js` — `workflowId` built deterministically from the ids in hand (`` `${tenantId}-${appId}-workflow` ``), `args` always an array holding one destructured-object payload.
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
# Testing & Verification
|
|
2
|
+
|
|
3
|
+
**When** work is proven and **which technology** proves it — the standing constraints that bind on every turn. The full lifecycle procedure — deriving a plan's Test Obligations, placing tests in strata, executing the verification gate — is the `testing` skill, invoked at those moments; this rule never restates it.
|
|
4
|
+
|
|
5
|
+
## Two regimes
|
|
6
|
+
|
|
7
|
+
**Deferral is a property of plans, not of code.** A plan carries a Test Obligations ledger and a gate that discharges it; nothing else does, so nothing else may defer.
|
|
8
|
+
|
|
9
|
+
| Regime | During the work | Proven |
|
|
10
|
+
| --- | --- | --- |
|
|
11
|
+
| **Plan-driven build** | No new test files. Code only — the loop stays fast | At the verification gate, before `Status: Complete` |
|
|
12
|
+
| **Bug fix** — a defect in shipped behavior | The regression test ships with the fix | Immediately — it fails before the change, passes after |
|
|
13
|
+
| **Ad-hoc / non-plan change** | The test ships with the change **when it alters promised behavior** — layout, copy, and config tweaks owe none | Immediately, same discipline as a bug fix |
|
|
14
|
+
|
|
15
|
+
- **A bug fix cannot defer** — "fails before, passes after" is provable afterward only by reverting to the pre-fix ref, a cost nobody pays; capture the property at fix time or lose it.
|
|
16
|
+
- **Ad-hoc work cannot defer — there is no ledger to defer into.** A non-plan change that alters no promised behavior — a pure refactor, a rename — owes no test.
|
|
17
|
+
|
|
18
|
+
## During the loop
|
|
19
|
+
|
|
20
|
+
Rules for the plan regime between the plan gate and the verification gate:
|
|
21
|
+
|
|
22
|
+
- **No new test files** — obligations wait in the plan's ledger (written at the plan gate by the `testing` skill).
|
|
23
|
+
- **Existing tests broken by the plan's intended behavior change are updated to the new contract before any mid-plan commit** — the shared branch never carries a red suite, and an update is never a weakening (§ Discipline).
|
|
24
|
+
- **A defect in the plan's own in-flux code is the loop iterating** — no test, no ledger row. **A pre-existing bug in shipped behavior is the bug-fix regime even mid-plan**: fix it with its regression test now — deferring it to the gate loses "fails before" — or send it to the backlog via Out-of-Scope Discovery. Never fix silently.
|
|
25
|
+
- **The ledger is amendable while the plan is open** — the gate discharges whatever it holds at gate time.
|
|
26
|
+
- **A mid-plan commit is a checkpoint, not a verification claim.**
|
|
27
|
+
|
|
28
|
+
## The gate
|
|
29
|
+
|
|
30
|
+
**A discrete, user-triggered step.** When implementation is complete, report it and offer the gate — never run it unbidden, and never flip `Status: Complete` without it. An explicit user acceptance of finished plan work — "ship it", "commit", "looks good" — counts as the trigger; open the gate without asking again. A plan with outstanding obligations stays open, whatever the code does. The procedure — checklist, drive-then-pin ladder, row discharge, what earns a durable test — is the `testing` skill (gate mode).
|
|
31
|
+
|
|
32
|
+
- **Precondition: the ledger exists.** A plan with no Test Obligations section is blocked at the gate — run the `testing` skill (plan-gate mode) on it first; an empty ledger passes vacuously and the gap surfaces only after the file is frozen.
|
|
33
|
+
- **The verdict is never delegated** (the root policy file § Delegation), and where browser work is main-session-only (its § Runtime Notes), the driving is too. Fan-out may prepare fixtures or draft test bodies; the driving and the verdict stay inline.
|
|
34
|
+
- **The gate needs no stronger tier than the session default** — a standard-tier session suffices whatever tier built the plan; the plan and its ledger carry all the state the gate needs, so gating in a fresh session loses nothing.
|
|
35
|
+
- **Report what happened, including failures.** A gate that reports green while a suite is red is worse than no gate; state the failing test and its output.
|
|
36
|
+
- **Abandonment does not erase debt.** Before `Status: Abandoned` freezes a plan whose code was committed, convert its undischarged rows into backlog items; uncommitted code owes nothing.
|
|
37
|
+
|
|
38
|
+
## Frameworks by stack
|
|
39
|
+
|
|
40
|
+
Framework choice is **never** ad hoc and never the user's problem mid-task: detect the stack, use its row; if the repo already has a runner, that runner wins — **never** introduce a second framework beside it.
|
|
41
|
+
|
|
42
|
+
| Stack | Unit / integration | Component | E2E | HTTP mocking | Don't use |
|
|
43
|
+
| --- | --- | --- | --- | --- | --- |
|
|
44
|
+
| Node + Vite / React (web) | `vitest` | `@testing-library/react` (+ `@testing-library/user-event`, `happy-dom`) | `@playwright/test` | `msw` | `jest`, `mocha`, `jasmine`, `cypress`, `enzyme` |
|
|
45
|
+
| Express APIs | `vitest` | — | `supertest` against the app instance | `msw` (outbound calls) | `jest`, `chai-http` |
|
|
46
|
+
| React Native / Expo | `jest` via the `jest-expo` preset | `@testing-library/react-native` | `maestro` | `msw` | `detox`, `enzyme` |
|
|
47
|
+
| Cloudflare Workers | `vitest` + `@cloudflare/vitest-pool-workers` | — | `@playwright/test` against `wrangler dev` | `msw` | `jest` |
|
|
48
|
+
| Rust | `cargo test` (built-in) | — | — | — | — |
|
|
49
|
+
|
|
50
|
+
Test packages install at `latest` — outside the approved-packages pin set. Fake data comes from `@ngneat/falso` — **never** `@faker-js/faker` / `chance`.
|
|
51
|
+
|
|
52
|
+
## Conventions
|
|
53
|
+
|
|
54
|
+
- **Suite, not scripts.** Tests live in the canonical test tree — `tests/` mirroring `src/`, or the convention the repo already uses — named `<subject>.test.ts`; shared fixtures, factories, and helpers in `tests/helpers/` (or the suite's existing shared home).
|
|
55
|
+
- **Wire the canonical command.** `"test"` in `package.json` runs the whole unit suite (`vitest run`); e2e under `"test:e2e"`. A repo where `pnpm test` / `npm test` doesn't run the suite is misconfigured — fix the wiring, **never** route around it.
|
|
56
|
+
- **Unit and E2E trees stay separate.** Vitest/Jest suites in `tests/`; Playwright/Maestro suites in `e2e/` with their own config — **never** mixed into the unit runner's glob.
|
|
57
|
+
- **No test doubles for what you own.** Prefer real instances — in-memory SQLite for drizzle, the actual Express app via `supertest` — over mocks; mock **only** true externals (network via `msw`, time via the runner's fake timers).
|
|
58
|
+
- **Determinism.** No real network, no real time, no shared mutable state between tests; every test **must** pass alone, in any order, **and from a cold shell** — never against a hand-started dev server (Workers: `@cloudflare/vitest-pool-workers`; Express: `supertest` against the app instance).
|
|
59
|
+
|
|
60
|
+
## Discipline
|
|
61
|
+
|
|
62
|
+
- **Never weaken, skip, or delete a test to mask an unexplained failure.** When a plan or LLD intentionally changes behavior, updating the affected tests to the new contract is part of that change.
|
|
63
|
+
- **A behavior that is compiler-guarded owes no test** — if it cannot ship green-but-broken, a test adds cost and no safety.
|
|
64
|
+
- **Throwaway checks are not tests.** Temporary scripts written to drive a change live in scratch space and are deleted — never committed into the test tree.
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: nustack
|
|
3
|
+
description: "The @nurix/* packaging and tooling skill — pnpm monorepo setup (workspace file, catalog, root scripts), publishing packages to npm, and distributable @nurix CLIs (npx self-update, managed blocks). Use when scaffolding a workspace, publishing a package, building a CLI, or debugging an npm publish."
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# NuStack
|
|
7
|
+
|
|
8
|
+
The `@nurix/*` packaging and tooling skill. Always-loaded workspace doctrine — package naming, monorepo boundaries, consuming — is the [`pnpm.md`](../../rules/pnpm.md) rule; this skill carries the on-demand reference material, one area per reference, and grows a new reference as an area is needed.
|
|
9
|
+
|
|
10
|
+
| Reference | Load when |
|
|
11
|
+
| --- | --- |
|
|
12
|
+
| [`references/monorepo-setup.md`](references/monorepo-setup.md) | scaffolding a new pnpm workspace, converting a repo to one, adding a workspace member, wiring the catalog / root scripts / build gating |
|
|
13
|
+
| [`references/monorepo-master-list.md`](references/monorepo-master-list.md) | the definitive tiered requirements (required / trigger-bound / deferred) — auditing a workspace's health, reviewing a monorepo PR, deciding what to enforce next |
|
|
14
|
+
| [`references/publishing.md`](references/publishing.md) | creating or publishing a `@nurix/*` package, wiring or debugging a publish workflow, diagnosing a failed `npm publish` |
|
|
15
|
+
| [`references/cli-design.md`](references/cli-design.md) | building or changing a distributable `@nurix/*` command-line tool |
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
# Designing a `@nurix/*` distributable CLI
|
|
2
|
+
|
|
3
|
+
Load when: building or changing a distributable `@nurix/*` command-line tool.
|
|
4
|
+
|
|
5
|
+
Conventions for building a distributable Nurix command-line tool, distilled from the two we ship: **`@nurix/etna`** (`packages/cli/` in the etna repo — the harness installer, plain ESM JS) and **`@nurix/apollo`** (`~/dev/apollo/packages/cli/` — the service-bootstrap CLI, TypeScript → `dist/`). When you build or change a `@nurix` CLI, follow these. Where the two diverge, both variants are noted as valid.
|
|
6
|
+
|
|
7
|
+
## 1. Packaging
|
|
8
|
+
|
|
9
|
+
The base `package.json` fields — scope, `publishConfig.access: "restricted"`, `files`, build hooks — are [`publishing.md`](publishing.md) §2. CLI-specific on top:
|
|
10
|
+
|
|
11
|
+
- **ESM only** — `"type": "module"`.
|
|
12
|
+
- **`bin` maps the command to its entry** — `{ "etna": "bin/etna.js" }` / `{ "apollo": "dist/index.js" }`. The entry has a `#!/usr/bin/env node` shebang.
|
|
13
|
+
- **`files` ships only the runnable artifact** — `["bin/", "data/", "README.md"]` (etna) or `["dist"]` (apollo). Never ship `src/`, tests, or the repo.
|
|
14
|
+
- **`engines.node` ≥ 22** for new CLIs (matches the workspace Node baseline).
|
|
15
|
+
- **One version source** — read the running version from `package.json` at startup; don't hardcode it elsewhere.
|
|
16
|
+
- **Minimal deps.** Both ship with essentially one runtime dep (`@clack/prompts`); apollo adds `commander` + `ajv`. Keep the dependency surface tiny — it's installed on every `npx` run.
|
|
17
|
+
|
|
18
|
+
## 2. Distribution: `npx`-first, install into the project, never globally
|
|
19
|
+
|
|
20
|
+
- The canonical entry is **`npx @nurix/<tool>`** — no global install step, no marketplace, no plugin-enablement dance. The user runs `npx`, the tool does its job in the current directory.
|
|
21
|
+
- **The CLI writes ONLY inside the folder it was invoked in.** An installer run as `npx @nurix/etna` in a project must touch only that project tree — never `$HOME`, never `~/.claude`, never any shared/user-level location. Reaching outside the target folder is surprising, clobber-prone, and wrong. (This is a hard-won rule: an earlier etna build installed agents to `~/.claude/agents/` and it was reverted precisely for this reason.)
|
|
22
|
+
- **Idempotent refresh.** Re-running the CLI re-pulls the latest and overwrites the files it owns (force), while leaving anything the user authored untouched. "Re-run anytime to refresh" must always be safe.
|
|
23
|
+
|
|
24
|
+
## 3. Always run the latest — self-update by re-exec
|
|
25
|
+
|
|
26
|
+
**The problem:** `npx` caches aggressively. A bare `npx @nurix/<tool>` can keep running a *stale* unpacked copy long after a newer version is published (npm's packument cache resolves an old `latest`, and npx reuses the matching install). Users shouldn't have to remember `@latest`.
|
|
27
|
+
|
|
28
|
+
**The mechanism** (etna's `selfUpdateOrReexec()`, run before anything else): on startup, ask the registry for the latest version; if this copy is behind, re-exec the latest and hand off.
|
|
29
|
+
|
|
30
|
+
```js
|
|
31
|
+
function selfUpdateOrReexec() {
|
|
32
|
+
if (process.env.TOOL_SELFUPDATED || process.env.TOOL_NO_SELFUPDATE) return;
|
|
33
|
+
const here = readJson(join(pkgRoot, 'package.json')).version;
|
|
34
|
+
if (!here) return;
|
|
35
|
+
const view = spawnSync('npm', ['view', '@nurix/<tool>', 'version', '--prefer-online'], { encoding: 'utf8' });
|
|
36
|
+
const latest = view.status === 0 ? (view.stdout || '').trim() : null;
|
|
37
|
+
if (!latest || latest === here) return; // current, offline, or unauthed → run this copy
|
|
38
|
+
const child = spawnSync('npx', ['-y', '--prefer-online', `@nurix/<tool>@${latest}`, ...process.argv.slice(2)],
|
|
39
|
+
{ stdio: 'inherit', env: { ...process.env, TOOL_SELFUPDATED: '1' } });
|
|
40
|
+
if (!child.error) process.exit(child.status ?? 0); // re-exec ran → hand off complete
|
|
41
|
+
// npx unavailable (child.error) → fall through and run this (older) copy.
|
|
42
|
+
}
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
Required properties:
|
|
46
|
+
|
|
47
|
+
- **`--prefer-online`** on both the `npm view` and the `npx` re-exec — defeats the metadata cache so npx fetches the fresh tarball.
|
|
48
|
+
- **Loop guard** — set `<TOOL>_SELFUPDATED=1` in the child's env so the re-exec'd process doesn't try to update again.
|
|
49
|
+
- **Opt-out** — `<TOOL>_NO_SELFUPDATE=1` skips the whole thing (CI, offline, or running an intentionally pinned/local-dev build). This is also how you test a local build.
|
|
50
|
+
- **Degrade gracefully** — *any* failure (offline, unauthed, `npm`/`npx` missing, timeout) falls through to running the current copy. The update is a convenience, never a gate.
|
|
51
|
+
- **Reuse existing npm auth** — the `npm view` rides the caller's npm credentials (the same auth that installed the restricted package); never prompt for login.
|
|
52
|
+
|
|
53
|
+
## 4. Editing the user's files: own a delimited block, idempotently
|
|
54
|
+
|
|
55
|
+
When the CLI must modify a file it doesn't own (`.env`, `settings.json`, `.gitignore`), it **owns only a delimited region** and rewrites that region in place — never the whole file.
|
|
56
|
+
|
|
57
|
+
- apollo wraps its `.env` writes in a managed block:
|
|
58
|
+
`# >>> apollo:managed — do not edit by hand; the apollo CLI rewrites this block >>>` … `# <<< apollo:managed <<<`. Reads/merges/removes happen *inside* the markers; everything outside belongs to the user.
|
|
59
|
+
- etna merges its hooks into `.claude/settings.json` and migrates/retires only its own prior entries.
|
|
60
|
+
- Rewrites are **idempotent** (re-running converges to the same block) and **reversible** (logout/uninstall removes exactly the managed region).
|
|
61
|
+
- If the block holds secrets, ensure `.gitignore` covers the file so they're never committed.
|
|
62
|
+
|
|
63
|
+
## 5. Interactive when it can, flag-driven when it can't
|
|
64
|
+
|
|
65
|
+
- Use **`@clack/prompts`** for the interactive path (TTY): intro/outro, spinners, notes.
|
|
66
|
+
- When **not a TTY**, require explicit flags and fail loudly with the correct syntax rather than guessing — e.g. etna needs `--name=<harness>` outside a TTY, and a removed/renamed flag (the dropped positional form in 0.18.0) errors with the new syntax instead of silently misfiring.
|
|
67
|
+
- A bare invocation in a TTY may present a menu; the same invocation headless must error, not hang.
|
|
68
|
+
|
|
69
|
+
## 6. Detect the package manager from the lockfile
|
|
70
|
+
|
|
71
|
+
When the CLI installs packages or runs scripts on the user's behalf, detect the manager: `pnpm-lock.yaml` → pnpm, `yarn.lock` → yarn, else npm. Never hardcode one. (apollo's `detectPackageManager()`.)
|
|
72
|
+
|
|
73
|
+
## 7. Publishing is CI-owned
|
|
74
|
+
|
|
75
|
+
Publish from CI on push to `dev`/`main`, path-filtered to the CLI package — never `npm publish` by hand for a routine change. The pipeline mechanics — workflow template, tags, skip directives, `registry-url`, monorepo `working-directory` — are [`publishing.md`](publishing.md) §3–5. CLI-specific on top:
|
|
76
|
+
|
|
77
|
+
- **Two valid version models:**
|
|
78
|
+
- **Fingerprint auto-bump (etna):** the build stamps a content hash (`fingerprint`) of the shipped payload; CI bumps the **patch** automatically when the fingerprint changed, publishes, and tags. Never hand-bump for a content change — let the fingerprint drive it; hand-edit the version only for a deliberate minor/major.
|
|
79
|
+
- **Manual bump input (apollo):** a `workflow_dispatch` `bump` input (`patch`/`minor`/`major`) drives `npm version`.
|
|
80
|
+
- **`npm version --no-git-tag-version`** (file-only), then commit + tag separately — `npm version` skips the git step when run in a subdirectory of the repo root.
|
|
81
|
+
|
|
82
|
+
## 8. Robustness defaults
|
|
83
|
+
|
|
84
|
+
- **Never hard-block on an advisory/network check.** Version currency, registry lookups, telemetry — all degrade to a one-line notice and continue.
|
|
85
|
+
- **Restricted-scope auth is ambient** — the read token in `~/.npmrc` (or `$NODE_AUTH_TOKEN` in CI) authenticates installs and `npm view`. Just attempt the operation; never prompt for `npm login` or reach for a substitute package.
|
|
86
|
+
- **Keep the hot path fast** — everything runs on every `npx`, so avoid heavy startup work beyond the self-update check.
|