@augurworks/augur 0.15.2 → 0.15.4

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.
@@ -11,6 +11,8 @@ import { createHash } from "node:crypto";
11
11
  import { merge3 } from "./merge3.mjs";
12
12
 
13
13
  export const STATE_FILE = ".augur/draft.json";
14
+ /** How many times `land` tries again when the space's manifest is contended. */
15
+ export const LAND_RETRIES = 4;
14
16
  export const THEIRS_DIR = ".augur/theirs";
15
17
  // The machine-wide registry of open draft folders. `AUGUR_DRAFTS_REGISTRY` exists for the
16
18
  // test suite, which must never write into the developer's own home folder.
@@ -28,6 +30,18 @@ const MIME = {
28
30
  export const mimeOf = (name) => MIME[path.extname(name).slice(1).toLowerCase()] || "application/octet-stream";
29
31
  export const hashBytes = (buf) => createHash("sha256").update(buf).digest("hex");
30
32
  export const relOf = (unit, urlPath) => urlPath.slice(unit.length);
33
+ /**
34
+ * A REPO folder or a URL, as the unit path it publishes to: `<project>/prototypes/<name>`
35
+ * is the nesting a space clone keeps, served at `/<project>/<name>/`. An agent has just been
36
+ * looking at the folder, so it is the spelling it will type.
37
+ */
38
+ export function unitPathFor(input) {
39
+ const s = String(input == null ? "" : input).trim().slice(0, 300).replace(/\/prototypes\//g, "/");
40
+ if (!s) return "";
41
+ const t = s.replace(/^\.\//, "").replace(/\/{2,}/g, "/");
42
+ if (!t || t === "/") return "/";
43
+ return `/${t.replace(/^\/+/, "").replace(/\/+$/, "")}/`;
44
+ }
31
45
  export const urlOf = (unit, rel) => unit + rel;
32
46
 
33
47
  export function scanFolder(dir) {
@@ -99,17 +113,30 @@ export const registryList = () => readRegistry().drafts;
99
113
  * `/__unit/<verb>` and blobs at `/__publish/<space>/blob/<hash>`. Non-2xx answers come back
100
114
  * as `{status, ...body}` rather than throwing, because a 409 is an answer, not a failure.
101
115
  */
116
+ /**
117
+ * The server answers a token it does not know with a bare `forbidden` and no sentence,
118
+ * ON PURPOSE — a guessed token must learn nothing. This side knows something the server
119
+ * will not say: the token it just sent is the one saved on this machine, so a bare
120
+ * refusal means that token is dead here — revoked by a role change or a removal, or
121
+ * minted for another workspace — and the way back is to pair again.
122
+ */
123
+ export const TOKEN_NOT_ACCEPTED = "this machine's publish token is not accepted here — it may have been revoked (a role change or a removal does that), or it belongs to another workspace. Run `augur connect` again.";
124
+ export function explainRefusal(body, status) {
125
+ if (status === 403 && body && body.error === "forbidden" && !body.message) return { ...body, message: TOKEN_NOT_ACCEPTED };
126
+ return body;
127
+ }
128
+
102
129
  export function unitClient({ origin, token, space, session }) {
103
130
  const headers = { Authorization: `Bearer ${token}`, "X-Augur-Session": session || "" };
104
131
  const post = async (verb, body) => {
105
132
  const r = await fetch(`${origin}/__unit/${verb}`, { method: "POST", headers: { ...headers, "content-type": "application/json" }, body: JSON.stringify(body) });
106
133
  const out = await r.json().catch(() => ({}));
107
- return r.ok ? out : { status: r.status, ...out };
134
+ return r.ok ? out : { status: r.status, ...explainRefusal(out, r.status) };
108
135
  };
109
136
  const get = async (verb, unit) => {
110
137
  const r = await fetch(`${origin}/__unit/${verb}?unit=${encodeURIComponent(unit)}`, { headers });
111
138
  const out = await r.json().catch(() => ({}));
112
- return r.ok ? out : { status: r.status, ...out };
139
+ return r.ok ? out : { status: r.status, ...explainRefusal(out, r.status) };
113
140
  };
114
141
  return {
115
142
  open: (b) => post("open", b), save: (b) => post("save", b), land: (b) => post("land", b),
@@ -117,16 +144,31 @@ export function unitClient({ origin, token, space, session }) {
117
144
  main: (unit) => get("main", unit),
118
145
  async blobPut(h, body) {
119
146
  const r = await fetch(`${origin}/__publish/${space}/blob/${h}`, { method: "PUT", headers, body });
120
- if (!r.ok && r.status !== 204) throw new Error(`blob upload failed: ${r.status}`);
147
+ if (!r.ok && r.status !== 204) throw await refusalError("blob upload", r);
121
148
  },
122
149
  async blobGet(h) {
123
150
  const r = await fetch(`${origin}/__publish/${space}/blob/${h}`, { headers });
124
- if (!r.ok) throw new Error(`blob fetch failed: ${r.status}`);
151
+ if (!r.ok) throw await refusalError("blob fetch", r);
125
152
  return Buffer.from(await r.arrayBuffer());
126
153
  },
127
154
  };
128
155
  }
129
156
 
157
+ /**
158
+ * A non-2xx answer from the blob routes, carried as an error that REMEMBERS it was an
159
+ * answer: the status and whatever the server said. `guarded` turns that back into a
160
+ * result, so a role refusal on an upload reads as the refusal it is — before this, a
161
+ * viewer's save came back as "unreachable, nothing is lost", which was neither.
162
+ */
163
+ async function refusalError(what, r) {
164
+ let body = null;
165
+ try { body = await r.json(); } catch (e) { /* not JSON */ }
166
+ const err = new Error(`${what} failed: ${r.status}${body && body.error ? ` ${body.error}` : ""}`);
167
+ err.status = r.status;
168
+ err.body = body && typeof body === "object" ? explainRefusal(body, r.status) : null;
169
+ return err;
170
+ }
171
+
130
172
  // ── the verbs ────────────────────────────────────────────────────────────────
131
173
  // `blobPut`/`blobGet` throw on a non-2xx answer, and a raw `fetch` can reject outright
132
174
  // (offline, DNS, a dropped connection mid-transfer) — a transient failure anywhere inside
@@ -138,7 +180,15 @@ export function unitClient({ origin, token, space, session }) {
138
180
  function guarded(fn) {
139
181
  return async (...args) => {
140
182
  try { return await fn(...args); }
141
- catch (err) { return { ok: false, error: "network", message: String((err && err.message) || err) }; }
183
+ catch (err) {
184
+ // An answer the server gave (a status) is a refusal and keeps the server's words;
185
+ // anything else — DNS, a dropped connection, a thrown fixture — is the network.
186
+ if (err && err.status) {
187
+ const body = err.body || {};
188
+ return { ok: false, status: err.status, error: body.error || "refused", message: body.message || String(err.message), ...(body.reason ? { reason: body.reason } : {}) };
189
+ }
190
+ return { ok: false, error: "network", message: String((err && err.message) || err) };
191
+ }
142
192
  };
143
193
  }
144
194
 
@@ -150,11 +200,19 @@ async function materialise(client, unit, table, dir) {
150
200
  }
151
201
  }
152
202
 
153
- async function doOpenImpl({ client, unit, dir, origin, space, session, now }) {
203
+ async function doOpenImpl({ client, unit, dir, origin, space, session, now, isNew = false }) {
154
204
  if (fs.existsSync(dir) && fs.readdirSync(dir).length) return { ok: false, error: "folder-not-empty", dir };
155
205
  const createdFolder = !fs.existsSync(dir);
156
206
  const o = await client.open({ unit });
157
207
  if (o.status) return { ok: false, ...o };
208
+ // A unit with no files is one that does not exist yet. Creating one is a decision the
209
+ // caller states with `isNew`; without it, a typo would quietly open an empty draft on a
210
+ // prototype that was never there. Either mismatch hands the draft straight back.
211
+ const exists = Object.keys(o.table || {}).length > 0;
212
+ if (exists !== !isNew) {
213
+ try { await client.discard({ unit, draftId: o.draftId }); } catch (e) { /* best-effort */ }
214
+ return { ok: false, error: exists ? "unit-exists" : "unknown-unit", unit };
215
+ }
158
216
  // From here the server-side draft exists, so any failure below must both undo what we
159
217
  // wrote to disk and tell the server to drop the orphan — otherwise a retry finds a
160
218
  // half-materialised folder (`folder-not-empty`) and the draft it opened is never freed.
@@ -177,7 +235,7 @@ async function doOpenImpl({ client, unit, dir, origin, space, session, now }) {
177
235
  throw err;
178
236
  }
179
237
  const others = (o.presence || []).filter((d) => d.id !== o.draftId);
180
- return { ok: true, draftId: o.draftId, address: `${origin}${o.address}`, files: Object.keys(o.table).length, others };
238
+ return { ok: true, draftId: o.draftId, address: `${origin}${o.address}`, files: Object.keys(o.table).length, others, isNew: !exists };
181
239
  }
182
240
 
183
241
  async function doSaveImpl({ client, dir, baseRevision, baseTable }) {
@@ -206,7 +264,17 @@ async function doLandImpl({ client, dir, note }) {
206
264
  if (!st) return { ok: false, error: "not-a-draft", dir };
207
265
  const saved = await doSave({ client, dir });
208
266
  if (!saved.ok) return saved;
209
- const r = await client.land({ unit: st.unit, draftId: st.draftId, baseRevision: st.baseRevision, note: note || "" });
267
+ // Every landing in a space writes the one manifest by compare-and-set, so eight agents
268
+ // landing eight prototypes in the same second contend on it. The server retries a few
269
+ // times and then answers `manifest-contended`; that is a moment, not a refusal — the
270
+ // draft is still open, its lease released — so this side lands again, with a little
271
+ // jitter, before telling anyone. Measured live: 8 parallel landings, 2 contended.
272
+ let r;
273
+ for (let attempt = 0; ; attempt++) {
274
+ r = await client.land({ unit: st.unit, draftId: st.draftId, baseRevision: st.baseRevision, note: note || "" });
275
+ if (r.error !== "manifest-contended" || attempt >= LAND_RETRIES) break;
276
+ await new Promise((res) => setTimeout(res, 150 + Math.random() * 400 * (attempt + 1)));
277
+ }
210
278
  if (r.status) return { ok: false, ...r };
211
279
  st.landed = true; st.landedRevision = r.revision;
212
280
  writeState(dir, st);
@@ -224,6 +224,7 @@ const ALLOWLIST = {
224
224
  "mcpHostAllowlist",
225
225
  "CANVAS_REGISTRY",
226
226
  "PITI_REMARKS",
227
+ "DERIVED", // the two store reads a derived gallery needs, per manifest version — the status baseline and the design-system catalog
227
228
  "ROSTER_OVERLAY",
228
229
  "FREEZE_STATE",
229
230
  "SUSPENSION_STATE",
@@ -281,6 +282,21 @@ const ALLOWLIST = {
281
282
  },
282
283
  },
283
284
 
285
+ // The derived pages: pure renderers over the live store. Every table here is a fact
286
+ // about the engine's vocabulary — tier names, status words and glyphs, the emoji pool a
287
+ // card's leading glyph is picked from — the same for every workspace.
288
+ "src/galleries.mjs": {
289
+ frozen: [
290
+ "TIERS", // the four library tiers whose demos are units
291
+ "TIER_TITLE", // their titles
292
+ "TIER_COPY", // their hints and empty states
293
+ "STATUS_META", // status word → label + class
294
+ "STATUS_ICONS", // status word → glyph
295
+ "STATUS_RANK", // the card order a status implies
296
+ "EMOJI_POOL", // the leading-emoji pool, picked by slug hash
297
+ ],
298
+ },
299
+
284
300
  "src/bundle-keys.mjs": {
285
301
  frozen: [
286
302
  "BUNDLE_TENANCY", // which bundle-store families carry a workspace segment; one word per family is the revert, and a deploy-wide fact — moved here from the worker so the workspace object shares the key shape
package/scripts/open.mjs CHANGED
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- // augur open <prototype> [--dir <folder>] [--session <label>]
2
+ // augur open <prototype> [--new] [--dir <folder>] [--session <label>]
3
3
  //
4
4
  // Open one prototype into a folder of its own, as a draft that is live at once at its own
5
5
  // address. Prints who else is drafting it. The folder holds only that prototype's files and
@@ -8,8 +8,7 @@
8
8
  import fs from "node:fs";
9
9
  import path from "node:path";
10
10
  import { target, buildStamp } from "./lib/store.mjs";
11
- import { markPathFor } from "./lib/marks.mjs";
12
- import { unitClient, doOpen } from "./lib/draft.mjs";
11
+ import { unitClient, doOpen, unitPathFor } from "./lib/draft.mjs";
13
12
  import { installAdapters } from "./lib/adapters.mjs";
14
13
  import { normUnit } from "../src/unit-core.mjs";
15
14
 
@@ -21,7 +20,7 @@ const positional = argv.filter((a, i) => !a.startsWith("--") && !(i > 0 && argv[
21
20
 
22
21
  const raw = positional[0];
23
22
  if (!raw) die("name a prototype: `augur open <opportunity>/<prototype>` (a folder path works too).");
24
- const unit = normUnit(markPathFor(raw) || raw);
23
+ const unit = normUnit(unitPathFor(raw) || raw);
25
24
  if (!unit) die(`"${raw}" is not a prototype path.`);
26
25
  let origin, token;
27
26
  try { ({ origin, token } = target({ needToken: true })); } catch (e) { die(e.message); }
@@ -35,15 +34,19 @@ const session = process.env.AUGUR_SESSION || opt("--session") || `session-${proc
35
34
  const dir = path.resolve(opt("--dir") || unit.split("/").filter(Boolean).pop());
36
35
 
37
36
  const client = unitClient({ origin, token, space, session });
38
- const r = await doOpen({ client, unit, dir, origin, space, session, now: new Date().toISOString() });
37
+ const isNew = argv.includes("--new");
38
+ const r = await doOpen({ client, unit, dir, origin, space, session, now: new Date().toISOString(), isNew });
39
39
  if (!r.ok) {
40
40
  if (r.error === "folder-not-empty") die(`${r.dir} is not empty — pick another folder with --dir.`);
41
+ if (r.error === "unknown-unit") die(`${unit} does not exist here. To create it: \`augur open --new ${unit.replace(/^\/|\/$/g, "")}\`.`);
42
+ if (r.error === "unit-exists") die(`${unit} exists already — open it without --new.`);
41
43
  if (r.error === "units-not-configured") die("this instance does not serve drafts yet (no unit store bound).");
42
44
  if (r.error === "bad-unit" && r.reason === "reserved-folder") die(`${unit} sits under a folder the engine reserves — a prototype lives at <opportunity>/<prototype>.`);
43
45
  if (r.error === "bad-unit" && r.reason === "not-a-prototype-folder") die(`${unit} is not a prototype folder — name one as <opportunity>/<prototype>.`);
44
- die(`could not open: ${r.error || r.status}${r.reason ? ` (${r.reason})` : ""}`);
46
+ if (r.error === "network") die(`could not reach the instance (${r.message}).`);
47
+ die(`could not open: ${r.error || r.status}${r.reason ? ` (${r.reason})` : ""}${r.message ? ` — ${r.message}` : ""}`);
45
48
  }
46
- log(`draft ${r.draftId} on ${unit} — ${r.files} file(s) in ${dir}`);
49
+ log(r.isNew ? `draft ${r.draftId} on ${unit} — a NEW prototype; ${dir} is empty, write its index.html there` : `draft ${r.draftId} on ${unit} — ${r.files} file(s) in ${dir}`);
47
50
  // The agent tool's hooks, installed for this machine the first time a draft is opened
48
51
  // here (idempotent; `AUGUR_NO_ADAPTERS=1` skips it — the suite and CI set it).
49
52
  if (!process.env.AUGUR_NO_ADAPTERS) {
@@ -25,6 +25,7 @@ import { readFile } from "node:fs/promises";
25
25
  import path from "node:path";
26
26
  import { fileURLToPath } from "node:url";
27
27
  import { findShellDir, deployConfig, originHost } from "./lib/instance.mjs";
28
+ import { draftsServed } from "./lib/draft.mjs";
28
29
  import { composePublish, filterLitter, unitPaths } from "./lib/publish-compose.mjs";
29
30
  import { collectEvidence } from "./lib/publish-evidence.mjs";
30
31
  import { stripVolatileHead } from "./lib/publish-conflict.mjs";
@@ -115,6 +116,17 @@ let ORIGIN = (process.env.AUGUR_ORIGIN || DEPLOY_ENV.AUGUR_ORIGIN ||
115
116
  deployConfig(ROOT, originHost(cwdSpaceOrigin)).siteOrigin || cwdSpaceOrigin || "")
116
117
  .replace(/\/+$/, "");
117
118
  if (!ORIGIN) die("no target origin — set AUGUR_ORIGIN, or add \"siteOrigin\" to space.json.");
119
+ // Where the instance serves drafts, a TREE is not what goes live any more: a prototype is
120
+ // changed by opening it and landing it (docs/drafts-that-land.md), and publishing a whole
121
+ // checkout there would put every session's half-done work on the site at once. The engine
122
+ // chrome (`--engine`) is not content and still goes this way.
123
+ if (!ENGINE_ONLY && await draftsServed(ORIGIN)) {
124
+ die(`${ORIGIN} serves drafts, so a prototype is changed by opening it, not by publishing a tree:\n\n` +
125
+ ` augur open <opportunity>/<prototype> # a folder of its own, live at once at its draft address\n` +
126
+ ` …edit; every save is live there…\n` +
127
+ ` augur land # the real URL moves; the last line printed is the live URL\n\n` +
128
+ `A prototype that does not exist yet: augur open --new <opportunity>/<name>. Read agents/drafts.md in the engine clone.`);
129
+ }
118
130
  // A WORKSPACE THAT MOVED answers its old address with a redirect. A checkout that still
119
131
  // names the old address (space.json not yet pulled, a stale AUGUR_ORIGIN) would otherwise
120
132
  // POST through that redirect and read the answer as a bad token. One GET with redirects
@@ -642,6 +654,13 @@ function selfUpdate(why) {
642
654
  if (NO_SELF_UPDATE || selfUpdateTried || process.env.AUGUR_SELF_UPDATED === "1") return false;
643
655
  selfUpdateTried = true;
644
656
  const git = (...a) => execFileSync("git", ["-C", ROOT, ...a], { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }).trim();
657
+ // A package install (`npm i -g @augurworks/augur`, or an `npx` run) has no clone to
658
+ // fast-forward. Say the one line that updates it, in the same place the clone would
659
+ // have updated itself, and let the caller report the skew.
660
+ if (!existsSync(path.join(ROOT, ".git"))) {
661
+ log(`this engine is a package install and is behind what the instance speaks (${why}) — update it: npm i -g @augurworks/augur@latest`);
662
+ return false;
663
+ }
645
664
  try {
646
665
  if (git("rev-parse", "--is-inside-work-tree") !== "true") return false;
647
666
  if (git("status", "--porcelain")) {
package/scripts/read.mjs CHANGED
@@ -3,9 +3,9 @@
3
3
  // under _read/<unit>/ beside the draft folders; files carry no write bit and the deny hook
4
4
  // refuses edits there. `augur close` inside it removes it. See docs/drafts-that-land.md §7.
5
5
  import path from "node:path";
6
- import { target } from "./lib/store.mjs";
7
- import { markPathFor } from "./lib/marks.mjs";
8
- import { unitClient, doRead, readDirFor } from "./lib/draft.mjs";
6
+ import fs from "node:fs";
7
+ import { target, buildStamp } from "./lib/store.mjs";
8
+ import { unitClient, doRead, readDirFor, unitPathFor } from "./lib/draft.mjs";
9
9
  import { normUnit } from "../src/unit-core.mjs";
10
10
 
11
11
  const log = (m) => console.error(`\x1b[35m[read]\x1b[0m ${m}`);
@@ -15,17 +15,24 @@ const opt = (n) => { const i = argv.indexOf(n); return i > -1 ? argv[i + 1] : nu
15
15
  const positional = argv.filter((a, i) => !a.startsWith("--") && !(i > 0 && argv[i - 1].startsWith("--")));
16
16
  const raw = positional[0];
17
17
  if (!raw) die("name a prototype: `augur read <opportunity>/<prototype>`.");
18
- const unit = normUnit(markPathFor(raw) || raw);
18
+ const unit = normUnit(unitPathFor(raw) || raw);
19
19
  if (!unit) die(`"${raw}" is not a prototype path.`);
20
20
  let origin, token;
21
21
  try { ({ origin, token } = target({ needToken: true })); } catch (e) { die(e.message); }
22
22
  const dir = path.resolve(opt("--dir") || readDirFor(unit));
23
- const client = unitClient({ origin, token, space: "", session: "" });
23
+ // The blob routes are per space, so the copy needs the space id exactly as `open` does:
24
+ // from a space.json in this folder, else from what the instance says it serves.
25
+ let space = null;
26
+ try { space = JSON.parse(fs.readFileSync("space.json", "utf8")).id; } catch (e) { /* not in a space folder */ }
27
+ if (!space) { try { space = Object.keys((await buildStamp(origin)).spaces || {})[0] || null; } catch (e) { /* stamp unreachable */ } }
28
+ if (!space) die("could not tell which space this instance serves — run from a folder with space.json, or set AUGUR_ORIGIN.");
29
+ const client = unitClient({ origin, token, space, session: "" });
24
30
  const r = await doRead({ client, unit, dir, origin, now: new Date().toISOString() });
25
31
  if (!r.ok) {
26
32
  if (r.error === "folder-not-empty") die(`${r.dir} is not empty — pick another folder with --dir.`);
27
33
  if (r.error === "units-not-configured") die("this instance does not serve drafts yet (no unit store bound).");
28
- die(`could not read: ${r.error || r.status}${r.reason ? ` (${r.reason})` : ""}`);
34
+ if (r.error === "network") die(`could not reach the instance (${r.message}).`);
35
+ die(`could not read: ${r.error || r.status}${r.reason ? ` (${r.reason})` : ""}${r.message ? ` — ${r.message}` : ""}`);
29
36
  }
30
37
  log(`${r.files} file(s) of ${unit} at revision ${r.revision}, read-only`);
31
38
  console.log(dir);
package/scripts/save.mjs CHANGED
@@ -15,7 +15,9 @@ const client = unitClient({ origin, token, space: st.space, session: st.session
15
15
  const r = await doSave({ client, dir });
16
16
  if (!r.ok) {
17
17
  if (r.error === "stale-draft" || r.error === "stale-draft-revision") die("this draft moved under you (another process saved to it) — run `augur sync`.");
18
- die(`save refused: ${r.error || r.status}`);
18
+ if (r.error === "draft-closed") die(`this draft was ${r.landed ? `landed by ${r.name || r.by || "someone"}${r.session ? ` (${r.session})` : ""} at ${r.at}` : "discarded"} — the folder is no longer a draft. Your edits are still here; run augur open on the prototype again and copy them in.`);
19
+ if (r.error === "network") die(`could not reach the instance (${r.message}). Nothing is lost — the next save carries every change since.`);
20
+ die(`save refused: ${r.error || r.status}${r.message ? ` — ${r.message}` : ""}`);
19
21
  }
20
22
  if (r.changed.length) console.error(`\x1b[35m[save]\x1b[0m ${r.changed.length} file(s) live at ${origin}${st.address}`);
21
23
  console.log(`${origin}${st.address}`);
@@ -16,7 +16,6 @@ import { execFileSync } from "node:child_process";
16
16
  import { existsSync, readFileSync, readdirSync } from "node:fs";
17
17
  import path from "node:path";
18
18
  import { resolveOrigin, resolveToken, apiClient, buildStamp, ENGINE_ROOT } from "./lib/store.mjs";
19
- import { fetchMarks, markLine } from "./lib/marks.mjs";
20
19
  import { registryList, unitClient, draftsReport } from "./lib/draft.mjs";
21
20
 
22
21
  const args = process.argv.slice(2);
@@ -123,28 +122,11 @@ console.log("");
123
122
  console.log(` ${C.dim}engine chrome ${eng.sha ? eng.sha.slice(0, 12) : "—"}` +
124
123
  `${eng.version ? ` (v${eng.version})` : ""}${eng.publishedAt ? ` · shipped ${eng.publishedAt}` : ""}${C.off}`);
125
124
 
126
- // ── who is working where, right now ─────────────────────────────────────────
127
- //
128
- // `F-presence-marks`. The three numbers above answer "is what I have what is live"; they
129
- // cannot answer "is somebody else in here", and that is the question that decides whether
130
- // the next hour of work is going to be merged or forked.
131
- //
132
- // NEEDS A TOKEN and does not ask for one. `status` has always been the command that runs
133
- // with nothing configured — the build stamp is public — so a missing credential quietly
134
- // costs the marks section and nothing else. It affects the exit code in no way: marks
135
- // refuse nothing, and a mark is not a problem.
136
- const markToken = resolveToken(origin);
137
- if (markToken) {
138
- const marks = await fetchMarks(apiClient(origin, markToken));
139
- if (marks.length) {
140
- console.log("");
141
- console.log(` ${C.dim}being worked on right now${C.off}`);
142
- for (const m of marks) console.log(` ${C.dim} ${markLine(m)}${C.off}`);
143
- }
144
- } else {
145
- // The one thing a fresh agent needs to know and has no other way to learn: this machine
146
- // holds no token, and the way to get one is pairing, not a password. Same words as the
147
- // instance's own /llms.txt. Costs nothing here — status never needed the token.
125
+ // ── not paired? say how, once ───────────────────────────────────────────────
126
+ // The one thing a fresh agent needs to know and has no other way to learn: this machine
127
+ // holds no token, and the way to get one is pairing, not a password. Same words as the
128
+ // instance's own /llms.txt. Costs nothing here status never needed the token.
129
+ if (!resolveToken(origin)) {
148
130
  console.log("");
149
131
  console.log(` ${C.dim}not paired with ${origin}. Publishing from here needs a token; get one without a password:${C.off}`);
150
132
  console.log(` ${C.dim} augur connect --origin ${origin} (the owner approves a code in a signed-in browser)${C.off}`);
package/scripts/sync.mjs CHANGED
@@ -15,7 +15,9 @@ const token = resolveToken(origin);
15
15
  if (!token) die("no publish token — run `augur connect` once.");
16
16
  const client = unitClient({ origin, token, space: st.space, session: st.session });
17
17
  const r = await doSync({ client, dir });
18
- if (!r.ok) die(`sync refused: ${r.error || r.status}`);
18
+ if (!r.ok && r.error === "draft-closed") die(`this draft was ${r.landed ? `landed by ${r.name || r.by || "someone"}${r.session ? ` (${r.session})` : ""} at ${r.at}` : "discarded"} — the folder is no longer a draft. Your edits are still here; run augur open on the prototype again and copy them in.`);
19
+ if (!r.ok && r.error === "network") die(`could not reach the instance (${r.message}). Nothing is lost — run augur sync again.`);
20
+ if (!r.ok) die(`sync refused: ${r.error || r.status}${r.message ? ` — ${r.message}` : ""}`);
19
21
  for (const f of r.taken) log(`took theirs ${f}`);
20
22
  for (const f of r.merged) log(`merged ${f}`);
21
23
  for (const c of r.conflicts) {