also looking at this
docs: add an uplink-conventions skill
#41
2 files
+157
-0
| @@ -76,6 +76,13 @@ Read the skill before building. The full list lives in `storage/framework/defaul | ||
| 76 | 76 | Every skill also has a docs page at https://stacksjs.com/docs/skills, one per skill, grouped by section |
| 77 | 77 | (`docs/skills/` in the framework repository; a scaffolded app's `docs/` is its own). |
| 78 | 78 | |
| 79 | **This app's own skill: read `app/Skills/uplink-conventions/SKILL.md` before touching anything under | |
| 80 | `app/Uplink/`, `app/Desktop/` or `config/`.** Two corrections that look obvious fail silently here: | |
| 81 | `config/uplink.ts` is not loaded by `@stacksjs/config`, whose override list is hardcoded and has no | |
| 82 | `uplink` key, so `config.uplink` is `undefined` rather than an error; and a name in | |
| 83 | `server-auto-imports.d.ts` is only a declaration, so a global that `injectGlobalAutoImports()` never | |
| 84 | installed typechecks and then throws at runtime. The skill has the working forms of both. | |
| 85 | ||
| 79 | 86 | ### Backend / API |
| 80 | 87 | | Task | Skill | |
| 81 | 88 | |---|---| |
| @@ -0,0 +1,150 @@ | ||
| 1 | --- | |
| 2 | name: uplink-conventions | |
| 3 | description: Use when adding or changing any Uplink subsystem - config, engines, the daemon, the menubar app. Covers where config belongs and how it actually loads, when auto-imports work and when they silently do not, and the framework-free constraint the downloadable app imposes. Read before writing code under app/Uplink/, app/Desktop/ or config/. | |
| 4 | license: MIT | |
| 5 | compatibility: Bun >= 1.3.0, TypeScript, macOS 13+ | |
| 6 | allowed-tools: Read Edit Write Bash Grep Glob | |
| 7 | --- | |
| 8 | ||
| 9 | # Uplink Conventions | |
| 10 | ||
| 11 | Uplink grew its own parallel conventions: a bespoke `UPLINK_*` environment reader outside `config/`, | |
| 12 | and an app that lives in `app/Uplink/` rather than the framework's directories. Use the framework's | |
| 13 | way instead. | |
| 14 | ||
| 15 | Two of the obvious corrections fail **silently**. That is what this skill is for. | |
| 16 | ||
| 17 | ## Key Paths | |
| 18 | ||
| 19 | - The daemon and the app: `app/Uplink/` | |
| 20 | - The compiled menubar binary's entry: `app/Desktop/launcher.ts` | |
| 21 | - Config directory: `config/` | |
| 22 | - The framework's config override list: `node_modules/@stacksjs/config/dist/overrides.js` | |
| 23 | (source: `storage/framework/core/config/src/overrides.ts`) | |
| 24 | - Auto-import injection: `node_modules/@stacksjs/server/dist/imports.js` | |
| 25 | ||
| 26 | ## The constraint everything else follows from | |
| 27 | ||
| 28 | Uplink ships two ways, and only one of them has a framework. | |
| 29 | ||
| 30 | 1. **From source**, `buddy uplink:watch`, a launchd service inside the Stacks app. | |
| 31 | 2. **The downloadable Uplink.app**, compiled with `bun build --compile` from | |
| 32 | `app/Desktop/launcher.ts`. No `.env`, no Stacks runtime, no database. It reads `settings.json` | |
| 33 | plus the Keychain and calls `loadConfig(settingsEnv(settings))`. | |
| 34 | ||
| 35 | ```bash | |
| 36 | grep -rn "@stacksjs" app/Uplink/*.ts app/Desktop/*.ts | |
| 37 | ``` | |
| 38 | ||
| 39 | One hit, and it is a comment. Keep it that way. Anything that pulls `@stacksjs/*` into a module the | |
| 40 | compiled app imports pulls the framework into the binary. | |
| 41 | ||
| 42 | ## Config: `config/uplink.ts` is not loaded by `@stacksjs/config` | |
| 43 | ||
| 44 | Putting a file in `config/` is right. Expecting `config.uplink` to appear is wrong. | |
| 45 | ||
| 46 | `@stacksjs/config` does not scan the directory. It walks a hardcoded list of about 46 | |
| 47 | `[key, filename]` pairs, and `uplink` is not one of them, nor should it be: that list is the | |
| 48 | framework's own surface. | |
| 49 | ||
| 50 | ```bash | |
| 51 | grep -c uplink node_modules/@stacksjs/config/dist/overrides.js # 0 | |
| 52 | ``` | |
| 53 | ||
| 54 | So `config.uplink` is `undefined`, and the usual `if (config.uplink?.pollMs)` reads as "not | |
| 55 | configured" rather than failing. Nothing throws. Nothing logs. | |
| 56 | ||
| 57 | **Use bunfig**, which is how stx loads its own config: | |
| 58 | ||
| 59 | ```typescript | |
| 60 | import { loadConfig } from 'bunfig' | |
| 61 | ||
| 62 | const config = await loadConfig({ | |
| 63 | name: 'uplink', // finds config/uplink.ts by name | |
| 64 | defaultConfig, // required; always returns at least this | |
| 65 | checkEnv: true, // derives UPLINK_* from the key names | |
| 66 | }) | |
| 67 | ``` | |
| 68 | ||
| 69 | Its default config directory is `resolve(process.cwd(), 'config')`, so the name alone finds the | |
| 70 | file, and it always returns at least the defaults. | |
| 71 | ||
| 72 | ### `checkEnv` already matches every variable Uplink documents | |
| 73 | ||
| 74 | bunfig builds the variable name from the config key: the prefix is `name.toUpperCase()` with dashes | |
| 75 | turned into underscores, and each key is split on capitals, | |
| 76 | `k.replace(/([A-Z])/g, '_$1').toUpperCase()`. | |
| 77 | ||
| 78 | | Config key | Derived variable | | |
| 79 | |---|---| | |
| 80 | | `pollMs` | `UPLINK_POLL_MS` | | |
| 81 | | `maxChars` | `UPLINK_MAX_CHARS` | | |
| 82 | | `ackAfterMs` | `UPLINK_ACK_AFTER_MS` | | |
| 83 | | `messagesDb` | `UPLINK_MESSAGES_DB` | | |
| 84 | | `claudeBin` | `UPLINK_CLAUDE_BIN` | | |
| 85 | | `codexPermission` | `UPLINK_CODEX_PERMISSION` | | |
| 86 | ||
| 87 | All seventeen of Uplink's current `UPLINK_*` names are exactly what `checkEnv` generates, so the | |
| 88 | hand-rolled `int()` and `str()` readers in `app/Uplink/config.ts` can go without renaming anything | |
| 89 | in `.env.example`. | |
| 90 | ||
| 91 | Two do not fit the pattern and stay hand-handled: `UPLINK_MODEL`, the legacy alias for | |
| 92 | `UPLINK_CLAUDE_MODEL`, and `CLAUDE_CODE_OAUTH_TOKEN`, which is not an Uplink variable at all. | |
| 93 | ||
| 94 | ### Two things that do not belong in a config file | |
| 95 | ||
| 96 | - **Values discovered at runtime.** `findClaude()` and `findCodex()` probe the filesystem for a | |
| 97 | binary. A static config file cannot hold that. The config file holds an optional override, | |
| 98 | `claudeBin`, and the probe stays in code, used when the override is empty. | |
| 99 | - **Anything the compiled app cannot reach.** `bunfig` is a runtime import, and it arrives through | |
| 100 | `better-dx`, a devDependency. Promote it to a real dependency before importing it at runtime, per | |
| 101 | the AGENTS.md rule that a package importing something at runtime declares it as a real dependency. | |
| 102 | ||
| 103 | ## Auto-imports: a declaration is not an injection | |
| 104 | ||
| 105 | `resources/functions/` is the right destination for genuinely shared code, and class exports such as | |
| 106 | `CodexEngine` work there. But a name appearing in `server-auto-imports.d.ts` only means `tsc` | |
| 107 | accepts it. | |
| 108 | ||
| 109 | The globals exist only after `injectGlobalAutoImports()` runs, and that happens at **server boot**, | |
| 110 | in `buddy serve` and `production-server.ts`. Uplink's engine runs in a launchd service and in | |
| 111 | `app/Commands/Uplink.ts`, which do not necessarily take that path. A bare `CodexEngine(...)` there | |
| 112 | typechecks and then throws `ReferenceError` at runtime. | |
| 113 | ||
| 114 | This is not hypothetical. It is stacksjs/stacks#2585, and AGENTS.md documents the same class of | |
| 115 | failure for the browser manifest. | |
| 116 | ||
| 117 | **So: keep explicit imports in service code.** That is not a compromise. The framework's own actions | |
| 118 | and models import what they use. | |
| 119 | ||
| 120 | ### Verification that actually proves it | |
| 121 | ||
| 122 | The first two steps pass even when it is broken, which is why all three are needed: | |
| 123 | ||
| 124 | ```bash | |
| 125 | buddy generate # 1. regenerate | |
| 126 | grep CodexEngine storage/framework/types/server-auto-imports.d.ts # 2. declared | |
| 127 | ``` | |
| 128 | ||
| 129 | ```bash | |
| 130 | # 3. the only one that proves injection, run inside the service process | |
| 131 | bun -e 'console.log(typeof globalThis.CodexEngine)' | |
| 132 | ``` | |
| 133 | ||
| 134 | Step 3 printing `undefined` while step 2 passes is exactly the failure this section exists to | |
| 135 | prevent. | |
| 136 | ||
| 137 | ### What not to move | |
| 138 | ||
| 139 | `messages-db.ts`, `typedstream.ts` and the sqlite plumbing stay where they are. They are internals | |
| 140 | with one caller each. Making them global costs namespace and gains nothing. | |
| 141 | ||
| 142 | Weigh any move the same way: auto-import buys something for stx templates, routes and actions. For a | |
| 143 | module that is only ever imported by other TypeScript, it buys nothing. | |
| 144 | ||
| 145 | ## Before you finish | |
| 146 | ||
| 147 | - `./buddy lint` and `./buddy typecheck`. | |
| 148 | - `./pantry/.bin/bun test tests/unit/uplink` using the pinned toolchain. The repo pins Bun 1.4.2 in | |
| 149 | `pantry.lock`; a different Bun can hang on the tests that spawn a subprocess and read its stdout. | |
| 150 | - If you touched anything the compiled app imports, re-check the grep at the top of this file. | |