A laptop that follows the table says when it needs credentials, and the last main defaults say development

The joining page, DEPLOY and the laptop sync default moved to development in the
commit before this one. This finishes the move and closes what a fresh clone's
friend test found.

- landing-preconditions diffs against origin/development when no landing branch
  is exported, instead of main.
- CLOUD-DEVELOPMENT-ENVIRONMENT, the plugin-designer skill (SKILL.md, workflow,
  glossary) and the shared dev server's notes land on and follow development;
  the skill says never to push to main.
- The sync fetches with the laptop's own credentials. git runs with prompts off,
  so on a clone with no credential helper it failed every interval with a bare
  "fetch-failed / remote: Unauthorized", which read as a broken server. That case
  is now its own state, needs-credentials: the log (once, not every tick) and
  /api/dev-sync say "dev-sync needs git credentials for <remote>". The joining
  page and .env.example say so, and that a fresh clone starts on main and must
  switch to development before the sync can fast-forward it.

Measured in scratch repos: the private remote with no helper gives
needs-credentials and one warning over two ticks; a local remote with no
DX_SYNC_BRANCH follows development and fast-forwards; a missing remote stays
fetch-failed.
This commit is contained in:
Doxa Maps 2026-09-29 00:28:01 +00:00
parent e57f64b64b
commit 1ca78d6d5d
9 changed files with 44 additions and 15 deletions

View file

@ -17,6 +17,7 @@ VITE_API_BASE_URL=https://pray.doxa.life
# Set to a remote name (origin) or a repo URL (https://…/repo.git) and `bun run dev` fetches every few
# seconds and fast-forwards when this checkout is on that branch with no local edits or commits; otherwise
# it only reports "development is N commits ahead" (in the log and at /api/dev-sync). It never merges over your work.
# Following needs read access to the repo (a git credential helper, or an SSH remote); without it the log says so.
DX_SYNC_REMOTE=
# The branch to follow. Empty = development (the shared table; main is production).
DX_SYNC_BRANCH=

View file

@ -36,12 +36,12 @@ is never written to a log.
**Your checkout has to be in a state where a fast-forward is safe.** All three of these:
- you are on the branch it follows (`DX_SYNC_BRANCH`, default `main`)
- you are on the branch it follows (`DX_SYNC_BRANCH`, default `development`)
- you have no unsaved edits
- you have no local commits of your own
> **This is the one that surprises people.** If you have your own work in progress, the sync **stops
> pulling** — it will not touch your files. It reports `main is N commits ahead` instead and waits.
> pulling** — it will not touch your files. It reports `development is N commits ahead` instead and waits.
>
> That is deliberate and it is protecting you. But it means: **push your own work, or you will drift.**
> If you leave local commits sitting for a day while your agent lands twenty changes, the pull you
@ -51,7 +51,7 @@ Two optional dials:
| | what it does | default |
|---|---|---|
| `DX_SYNC_BRANCH` | which branch to follow | `main` |
| `DX_SYNC_BRANCH` | which branch to follow | `development` |
| `DX_SYNC_INTERVAL` | seconds between checks | `5` |
---

View file

@ -93,6 +93,14 @@ DX_SYNC_REMOTE=origin
That is the only line you need. `.env.local` is never committed, so it stays yours.
Two things it needs from you, once:
- **Be on `development`.** It only fast-forwards the branch it follows, and a fresh clone starts on `main`,
so run `git switch development` (or clone with `-b development`).
- **Read access to the repo.** It fetches with your laptop's own git credentials: a credential helper, or an
SSH remote your key can read. Without them nothing is pulled, and the log (and `/api/dev-sync`) says
`dev-sync needs git credentials for <remote>` rather than failing quietly.
> **Either a remote name or a full repo URL works.** `origin` is what almost everyone wants. A URL —
> `https://…/your-repo.git` — works too and saves you a `git remote add`; if the repo is private it uses
> the git credentials your laptop already has. Leave it unset and the whole feature is off.

View file

@ -1,11 +1,11 @@
/**
* dev-server.mjs — the SHARED DEV SERVER every agent can check against (headless: curl-style fetches).
*
* One `bun run dev` on this machine follows origin/main (fast-forward, ~30 s) from a checkout that is
* nobody's worktree, so it shows what a developer running the dev server on main sees. It answers
* One `bun run dev` on this machine follows origin/development (fast-forward, ~30 s) from a checkout that is
* nobody's worktree, so it shows what a developer running the dev server on development sees. It answers
* /__dev-sha with the commit it is serving.
*
* USE IT FOR: "does main work under the dev server", dev-parity checks, and waiting for a landing
* USE IT FOR: "does development work under the dev server", dev-parity checks, and waiting for a landing
* to appear. DO NOT use it for an UNLANDED change (it does not have it): start a short-lived dev
* server in your own worktree, or BUNDLE=<name> bun x vite build. Release/embed checks (SRI, manifest,
* iframe/script cells) stay on the BUILT tree.

View file

@ -21,7 +21,16 @@ import { execFile } from 'node:child_process'
const git = (root, args) => new Promise((ok) => execFile('git', args,
{ cwd: root, timeout: 60000, env: { ...process.env, GIT_TERMINAL_PROMPT: '0' } },
(e, out, err) => ok({ ok: !e, out: String(out || '').trim(), err: String(err || e?.message || '').trim().split('\n')[0] })))
(e, out, err) => {
const all = String(err || e?.message || '').trim()
ok({ ok: !e, out: String(out || '').trim(), err: all.split('\n')[0], errAll: all })
}))
// A fetch refused for want of CREDENTIALS is not a network blip: it fails identically every interval until
// this laptop can read the repo. git runs with GIT_TERMINAL_PROMPT=0 (a prompt would hang the dev server), so
// with no credential helper it can only fail, and a bare "fetch-failed / remote: Unauthorized" read as a
// broken server. Measured on a fresh clone: the same fetch succeeded once credentials were supplied.
const AUTH_RE = /unauthori[sz]ed|authentication failed|could not read (username|password)|terminal prompts disabled|permission denied \(publickey|\b40[13]\b/i
// What the log and /api/dev-sync show: never the credentials of a https://user:token@host/… URL.
const shown = (remote) => remote.replace(/\/\/[^/@]*@/, '//')
@ -37,7 +46,15 @@ export async function syncOnce(root, remote, branch, log = { info() {}, warn() {
const src = label(remote, branch)
const s = { enabled: true, remote: shown(remote), branch, checkedAt: new Date().toISOString() }
const f = await git(root, ['fetch', '-q', remote, branch])
if (!f.ok) return (REG.status = { ...s, state: 'fetch-failed', message: `could not fetch ${src}: ${shown(f.err)}` })
if (!f.ok) {
const auth = AUTH_RE.test(f.errAll)
const state = auth ? 'needs-credentials' : 'fetch-failed'
const message = auth
? `dev-sync needs git credentials for ${shown(remote)} (a credential helper, or an SSH remote your key can read); git said: ${shown(f.err)}`
: `could not fetch ${src}: ${shown(f.err)}`
if (REG.status.state !== state || REG.status.message !== message) log.warn(`[dev-sync] ${message}`)
return (REG.status = { ...s, state, message })
}
const up = (await git(root, ['rev-parse', 'FETCH_HEAD'])).out
const head = (await git(root, ['rev-parse', 'HEAD'])).out
const cur = (await git(root, ['symbolic-ref', '-q', '--short', 'HEAD'])).out

View file

@ -106,7 +106,7 @@ if (process.env.DOXA_SKIP_FRESHNESS === '1') {
// dependencies) reaches ANY bundle — measured 2026-09-25: one Arabic catalogue edit in library/i18n left 12
// of 25 bundles stale, most of them not composites, and a composite-only check saw none of it. Otherwise:
// every composite, plus each bundle whose own folder the landing touched.
const base = run('git', ['merge-base', 'HEAD', `origin/${process.env.MAIN_CAPTURE_BRANCH || 'main'}`]).stdout.trim()
const base = run('git', ['merge-base', 'HEAD', `origin/${process.env.MAIN_CAPTURE_BRANCH || process.env.LANDING_BRANCH || 'development'}`]).stdout.trim()
const changed = base ? run('git', ['diff', '--name-only', base, 'HEAD']).stdout.split('\n').filter(Boolean) : []
// ★ A library/ change no longer means EVERY bundle. It means every bundle whose SOURCE GRAPH contains a
// changed file, or that reads a changed i18n area: measured, a one-file library fix reached 1 of 24

View file

@ -213,7 +213,7 @@ read the pin and label layers in `plugin-designs/template-bundle/template-map-a.
```bash
bun run dev # the Plugin Studio at http://localhost:5173/
BUNDLE=<your-plugin> bun x vite build # just your bundle → public/dx-maps/<your-plugin>/
git add -A && git commit -m "…" && git pull --rebase origin main && git push origin HEAD:main
git add -A && git commit -m "…" && git pull --rebase origin development && git push origin HEAD:development
```
- Your plugin appears in the studio as soon as its folder exists; edits reload live. One screen

View file

@ -76,4 +76,4 @@ So "give it a label" is a gate, not a convention. Run `bun run check:studio-rail
- `targets.md` — capabilities, `targets`, and where a plugin is allowed to run
- `backend.md` — data and the user tiers (design intent, not built)
- `workflow.md` — the loop from empty folder to landed on main
- `workflow.md` — the loop from empty folder to landed on development

View file

@ -1,4 +1,4 @@
# The workflow: empty folder to landed on main
# The workflow: empty folder to landed on development
## 1. Develop in the Plugin Studio
@ -43,14 +43,17 @@ Run the gates that cover what you touched. The ones most often relevant:
`package.json` holds the full list; they are all prefixed `check:`.
## 4. Land it on main
## 4. Land it on development
`development` is where everyone lands; `main` is production and moves only when `development` is merged
into it, so never push to `main`.
Three commands, in this order, every time:
```bash
git add -A && git commit -m "what you changed"
git pull --rebase origin main
git push origin HEAD:main
git pull --rebase origin development
git push origin HEAD:development
```
**A rejected push is normal, not an error.** Someone landed first: run the middle command again and