@augurworks/augur 0.15.1

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.
Files changed (190) hide show
  1. package/CANVAS.md +858 -0
  2. package/INSTALL.md +603 -0
  3. package/LICENSE +27 -0
  4. package/README.md +150 -0
  5. package/TRADEMARKS.md +72 -0
  6. package/agents/README.md +65 -0
  7. package/agents/canon-extract.md +117 -0
  8. package/agents/canon.md +151 -0
  9. package/agents/canvas.md +88 -0
  10. package/agents/currency.md +74 -0
  11. package/agents/drafts.md +92 -0
  12. package/agents/identity.md +148 -0
  13. package/agents/prototype-contract.md +69 -0
  14. package/agents/publishing.md +324 -0
  15. package/agents/review-feedback.md +76 -0
  16. package/agents/space-json.md +132 -0
  17. package/agents/ui-skill.md +101 -0
  18. package/agents/working-marks.md +86 -0
  19. package/brand/augur-eye.svg +3 -0
  20. package/brand/augur-icon.svg +41 -0
  21. package/brand/augur-mark.svg +5 -0
  22. package/brand/lentianova-bulky.otf +0 -0
  23. package/brand/lentianova-median.otf +0 -0
  24. package/brand/lentianova.woff2 +0 -0
  25. package/build.js +8409 -0
  26. package/changelog.md +133 -0
  27. package/fonts/inter-latin-wght-normal.woff2 +0 -0
  28. package/package.json +57 -0
  29. package/pitis/PETS.md +121 -0
  30. package/pitis/README.md +117 -0
  31. package/pitis/index.html +266 -0
  32. package/pitis/piti.build.js +81 -0
  33. package/pitis/piti.js +929 -0
  34. package/pitis/roast-agent.md +185 -0
  35. package/realtime/src/index.js +119 -0
  36. package/realtime/wrangler.example.toml +44 -0
  37. package/scripts/adopt.mjs +106 -0
  38. package/scripts/board-snapshot.mjs +197 -0
  39. package/scripts/build-seed-pack.mjs +33 -0
  40. package/scripts/bundle-rekey.mjs +112 -0
  41. package/scripts/bundle-tenancy-rehearsal.mjs +781 -0
  42. package/scripts/canon-extract.mjs +578 -0
  43. package/scripts/canon.mjs +599 -0
  44. package/scripts/canvas-screen.mjs +333 -0
  45. package/scripts/check-local.mjs +136 -0
  46. package/scripts/clawd-art.mjs +75 -0
  47. package/scripts/clawd-canvas.mjs +741 -0
  48. package/scripts/cli.mjs +100 -0
  49. package/scripts/clone.mjs +422 -0
  50. package/scripts/close.mjs +30 -0
  51. package/scripts/connect.mjs +103 -0
  52. package/scripts/deploy.mjs +157 -0
  53. package/scripts/dev.mjs +54 -0
  54. package/scripts/doc-lint.mjs +143 -0
  55. package/scripts/export.mjs +300 -0
  56. package/scripts/fork.mjs +64 -0
  57. package/scripts/freeze.mjs +74 -0
  58. package/scripts/frontdoor-parity.mjs +180 -0
  59. package/scripts/hook.mjs +67 -0
  60. package/scripts/identity-rekey.mjs +112 -0
  61. package/scripts/init.mjs +122 -0
  62. package/scripts/land.mjs +40 -0
  63. package/scripts/lib/adapters.mjs +156 -0
  64. package/scripts/lib/board-snapshot.mjs +511 -0
  65. package/scripts/lib/draft.mjs +385 -0
  66. package/scripts/lib/graduate.mjs +276 -0
  67. package/scripts/lib/instance.mjs +74 -0
  68. package/scripts/lib/marks.mjs +107 -0
  69. package/scripts/lib/materialize.mjs +138 -0
  70. package/scripts/lib/merge3.mjs +124 -0
  71. package/scripts/lib/offline-posture.mjs +120 -0
  72. package/scripts/lib/offline-respawn.mjs +18 -0
  73. package/scripts/lib/offline-wrangler.mjs +78 -0
  74. package/scripts/lib/publish-compose.mjs +12 -0
  75. package/scripts/lib/publish-conflict.mjs +78 -0
  76. package/scripts/lib/publish-evidence.mjs +144 -0
  77. package/scripts/lib/refine-compare.mjs +140 -0
  78. package/scripts/lib/refine-ledger.mjs +150 -0
  79. package/scripts/lib/refine-png.mjs +149 -0
  80. package/scripts/lib/seed-pack-build.mjs +141 -0
  81. package/scripts/lib/state-compare.mjs +68 -0
  82. package/scripts/lib/store.mjs +122 -0
  83. package/scripts/login.mjs +84 -0
  84. package/scripts/mark.mjs +112 -0
  85. package/scripts/migrate-board-keys.mjs +164 -0
  86. package/scripts/migrate.mjs +265 -0
  87. package/scripts/no-foreign-vocabulary.mjs +216 -0
  88. package/scripts/no-product-names.mjs +157 -0
  89. package/scripts/no-tenant-globals.mjs +1134 -0
  90. package/scripts/offline.mjs +247 -0
  91. package/scripts/og.mjs +170 -0
  92. package/scripts/one-tenant-resolver.mjs +146 -0
  93. package/scripts/open.mjs +63 -0
  94. package/scripts/publish.mjs +1041 -0
  95. package/scripts/read.mjs +31 -0
  96. package/scripts/refine.mjs +455 -0
  97. package/scripts/release-drift.mjs +138 -0
  98. package/scripts/restore.mjs +322 -0
  99. package/scripts/review.mjs +125 -0
  100. package/scripts/save.mjs +21 -0
  101. package/scripts/shell-lint.mjs +189 -0
  102. package/scripts/ship.mjs +460 -0
  103. package/scripts/shoot.mjs +264 -0
  104. package/scripts/smoke-invite.mjs +113 -0
  105. package/scripts/state-inventory.mjs +223 -0
  106. package/scripts/status.mjs +177 -0
  107. package/scripts/sync.mjs +27 -0
  108. package/scripts/tenant-do-rehearsal.mjs +1314 -0
  109. package/scripts/ui-copy-lint.mjs +202 -0
  110. package/scripts/watch.mjs +32 -0
  111. package/scripts/wrangler-preflight.mjs +246 -0
  112. package/seed/CANON.md +43 -0
  113. package/seed/README.md +119 -0
  114. package/seed/prototype-status.json +9 -0
  115. package/seed/registry.json +88 -0
  116. package/seed/skills/starter-ui/SKILL.md +52 -0
  117. package/seed/skills/starter-ui/skill.json +11 -0
  118. package/seed/skills/starter-ui/starter-tokens.css +73 -0
  119. package/seed/skills/starter-ui/starter-ui.css +318 -0
  120. package/seed/skills/starter-ui/starter-ui.js +50 -0
  121. package/seed/space.json +5 -0
  122. package/seed/start-here/prototypes/connect-your-terminal/index.html +428 -0
  123. package/seed/start-here/prototypes/connect-your-terminal/preview.webp +0 -0
  124. package/seed/start-here/prototypes/sample-with-comments/index.html +172 -0
  125. package/seed/start-here/prototypes/sample-with-comments/preview.webp +0 -0
  126. package/seed/start-here/prototypes/set-up-your-design-system/index.html +353 -0
  127. package/seed/start-here/prototypes/set-up-your-design-system/preview.webp +0 -0
  128. package/seed/threads.json +52 -0
  129. package/seed/worked-examples/prototypes/field-readings/index.html +273 -0
  130. package/seed/worked-examples/prototypes/field-readings/preview.webp +0 -0
  131. package/seed/worked-examples/prototypes/slide-deck/index.html +214 -0
  132. package/seed/worked-examples/prototypes/slide-deck/preview.webp +0 -0
  133. package/seed/worked-examples/prototypes/specimen-viewer/index.html +203 -0
  134. package/seed/worked-examples/prototypes/specimen-viewer/preview.webp +0 -0
  135. package/src/_worker.js +12668 -0
  136. package/src/board-key.mjs +57 -0
  137. package/src/board-room.mjs +881 -0
  138. package/src/bundle-keys.mjs +138 -0
  139. package/src/canon/collect-in-browser.js +215 -0
  140. package/src/canon/emit.mjs +232 -0
  141. package/src/canon/observe.mjs +260 -0
  142. package/src/canon/schema.mjs +451 -0
  143. package/src/canvas/DSEG-LICENSE.txt +95 -0
  144. package/src/canvas/DSEG7Classic-Bold.woff2 +0 -0
  145. package/src/canvas/canvas.css +885 -0
  146. package/src/canvas/canvas.js +5780 -0
  147. package/src/canvas/capture.js +485 -0
  148. package/src/chrome/appchrome.mjs +568 -0
  149. package/src/currency.mjs +203 -0
  150. package/src/delete-confirmation.mjs +254 -0
  151. package/src/drafts/drafts.js +326 -0
  152. package/src/entry.js +58 -0
  153. package/src/galleries.mjs +400 -0
  154. package/src/health-cron.mjs +173 -0
  155. package/src/identity.json +1 -0
  156. package/src/kv-codec.mjs +135 -0
  157. package/src/kv-identity.mjs +259 -0
  158. package/src/mail.mjs +599 -0
  159. package/src/provenance.mjs +74 -0
  160. package/src/publish-compose.mjs +363 -0
  161. package/src/publish-fork.mjs +221 -0
  162. package/src/publish-units.mjs +44 -0
  163. package/src/purge.mjs +81 -0
  164. package/src/review/COMMENTING-UX.md +306 -0
  165. package/src/review/cat.png +0 -0
  166. package/src/review/comment-cursor.svg +7 -0
  167. package/src/review/comments.js +1738 -0
  168. package/src/room-ticket.mjs +96 -0
  169. package/src/roster-chip.mjs +32 -0
  170. package/src/seed-pack.mjs +261 -0
  171. package/src/state-inventory.mjs +249 -0
  172. package/src/sw-logic.mjs +48 -0
  173. package/src/tenant-cache.mjs +141 -0
  174. package/src/tenant-context.mjs +386 -0
  175. package/src/tenant-do.js +2940 -0
  176. package/src/tenant-host.mjs +144 -0
  177. package/src/tenant-quotas.mjs +140 -0
  178. package/src/unit-core.mjs +103 -0
  179. package/src/unit-object.mjs +338 -0
  180. package/templates/README.md +67 -0
  181. package/templates/shell/deploy.yml +145 -0
  182. package/templates/shell/engine-bump.yml +142 -0
  183. package/templates/shell/health.yml +233 -0
  184. package/templates/shell/kv-backup.yml +201 -0
  185. package/templates/shell/roster-update.yml +110 -0
  186. package/templates/shell/space-preflight.yml +35 -0
  187. package/templates/shell/space-rebake.yml +79 -0
  188. package/templates/shell/store-backup.yml +152 -0
  189. package/templates/shell/wrangler.example.toml +266 -0
  190. package/templates/space/publish.yml +91 -0
@@ -0,0 +1,100 @@
1
+ #!/usr/bin/env node
2
+ // augur — one entry for the platform commands.
3
+ //
4
+ // augur dev full local shell in the current space folder (or workspace root)
5
+ // augur build compose dist/ once
6
+ // augur deploy build + direct-upload the whole site (see deploy.mjs)
7
+ // augur ship commit + publish + push — the default way a change goes out
8
+ // augur publish publish only, without committing or pushing (see publish.mjs)
9
+ // augur fork copy a published artifact to a new URL, moving no bytes (see fork.mjs)
10
+ // augur status what is live vs what your clones hold (see status.mjs)
11
+ // augur refine render every component, photograph it, measure it against the
12
+ // original, and report a pass-rate nobody can assert (see refine.mjs)
13
+ // augur mark say what you are about to work on; read what everyone else is
14
+ // augur open open one prototype into a folder of its own, live at once
15
+ // augur save push every changed file in this draft folder
16
+ // augur land replace the prototype's main with this draft
17
+ // augur sync fold what landed on main since this draft opened into the draft
18
+ // augur close remove this draft folder (see docs/drafts-that-land.md)
19
+ // augur read a read-only copy of a prototype, for context
20
+ // augur watch save this draft folder on every burst of changes
21
+ // augur hook the agent tool's hooks: pre|post (stdin), install|remove|status
22
+ // augur export take an off-Cloudflare copy of the store (see export.mjs)
23
+ // augur restore put a copy back (see restore.mjs)
24
+ // augur migrate move a workspace to another instance, and prove it arrived
25
+ // augur freeze make a workspace read-only while it is being moved
26
+ // augur thaw accept writes again, and print how long the freeze lasted
27
+ //
28
+ // Each subcommand is its own script with its own --help-worthy header; this
29
+ // router only dispatches, so `node scripts/<name>.mjs` keeps working too.
30
+
31
+ import { spawn } from "node:child_process";
32
+ import path from "node:path";
33
+ import { fileURLToPath } from "node:url";
34
+
35
+ const SCRIPTS = path.dirname(fileURLToPath(import.meta.url));
36
+ const sub = process.argv[2];
37
+ const rest = process.argv.slice(3);
38
+ const map = {
39
+ init: "init.mjs",
40
+ dev: "dev.mjs",
41
+ offline: "offline.mjs",
42
+ deploy: "deploy.mjs",
43
+ ship: "ship.mjs",
44
+ publish: "publish.mjs",
45
+ // The one publishing verb that needs no tree: two paths and a token (see fork.mjs).
46
+ fork: "fork.mjs",
47
+ status: "status.mjs",
48
+ refine: "refine.mjs",
49
+ mark: "mark.mjs",
50
+ // Drafts that land (docs/drafts-that-land.md): one prototype, one folder, live at once.
51
+ open: "open.mjs",
52
+ save: "save.mjs",
53
+ land: "land.mjs",
54
+ sync: "sync.mjs",
55
+ close: "close.mjs",
56
+ read: "read.mjs",
57
+ watch: "watch.mjs",
58
+ // The agent tool's hooks (deny outside a draft, save after an edit) and their install.
59
+ hook: "hook.mjs",
60
+ export: "export.mjs",
61
+ restore: "restore.mjs",
62
+ login: "login.mjs",
63
+ connect: "connect.mjs",
64
+ clone: "clone.mjs",
65
+ // Same script again: `thaw` is `freeze` in the other direction, and one file is what
66
+ // keeps the two from disagreeing about which paths a freeze closes.
67
+ freeze: "freeze.mjs",
68
+ thaw: "freeze.mjs",
69
+ migrate: "migrate.mjs",
70
+ // NOT a migration either, and the third name in this neighbourhood on purpose: a re-key
71
+ // moves one workspace's content onto the store's workspace segment WITHOUT it leaving the
72
+ // instance, the bucket, or its own hostname. `migrate` cannot do it — it is
73
+ // origin-addressed and a restore lands every space at v1, which would strand the history.
74
+ "bundle-rekey": "bundle-rekey.mjs",
75
+ "identity-rekey": "identity-rekey.mjs",
76
+ // NOT a synonym for migrate, and the names are kept apart on purpose: migrate MOVES a
77
+ // workspace to another instance, adopt copies THIS instance's KV into its own object.
78
+ adopt: "adopt.mjs",
79
+ // Same script: `pull` is `clone` with a three-way merge instead of an overwrite, and one
80
+ // file is what keeps the URL→source mapping from being written twice.
81
+ pull: "clone.mjs",
82
+ // The canon: resolve a canonical name to files, and promote a working screen into it.
83
+ canon: "canon.mjs",
84
+ build: path.join("..", "build.js"),
85
+ };
86
+ if (!map[sub]) {
87
+ console.error("usage: augur <init|ship|dev|offline|build|deploy|publish|fork|status|canon|refine|mark|open|save|land|sync|close|read|watch|hook|clone|pull|export|restore|migrate|bundle-rekey|identity-rekey|adopt|freeze|thaw|connect|login> [options]");
88
+ process.exit(sub ? 1 : 0);
89
+ }
90
+ const child = spawn(process.execPath, [path.join(SCRIPTS, map[sub]), ...rest], {
91
+ stdio: "inherit",
92
+ // `clone` and `pull` share a script; the verb is how it knows which one was asked for.
93
+ env: {
94
+ ...process.env,
95
+ AUGUR_CLONE_MODE: sub === "pull" ? "pull" : "clone",
96
+ // Which verb was typed, for the two scripts that serve two of them.
97
+ AUGUR_CMD: sub,
98
+ },
99
+ });
100
+ child.on("close", (code) => process.exit(code ?? 0));
@@ -0,0 +1,422 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * augur clone / augur pull — turn a live space back into an editable source tree.
4
+ *
5
+ * `C-clone-pull-materializer`, and it serves the goal that erodes quietly: leaving is free,
6
+ * and self-hosting is the same product. A hosted workspace may never have had a git repo,
7
+ * so "export your data" cannot mean "here is a tarball of hashes" — it has to mean a tree
8
+ * somebody can open, edit and publish again.
9
+ *
10
+ * augur clone --space <id> [--out <dir>] [--dry-run]
11
+ * augur pull [--space <id>] [--dry-run] (from inside a cloned tree)
12
+ *
13
+ * ── ONE PROTOTYPE, STANDALONE (the graduation form) ──────────────────────────────────
14
+ *
15
+ * augur clone --prototype <project>/<name> [--space <id>] [--out <dir>] [--dry-run]
16
+ * augur clone --prototype <name> --from <space-dir> [--out <dir>] (no credentials)
17
+ *
18
+ * `F-graduate-path`. The whole-workspace clone above serves "leaving is free" at workspace
19
+ * granularity; this serves it at ARTIFACT granularity, which is the one observed usage
20
+ * actually asks for. A single tool inside a research workspace acquires a stable audience
21
+ * that is not the team — and then it should leave, on its own, before the research
22
+ * workspace becomes its production host by default. It is a doorway, not a policy: see
23
+ * docs/graduation.md for WHEN, which is the harder half.
24
+ *
25
+ * What comes out is a folder any static host can serve at a domain root: the prototype's
26
+ * files re-rooted (its index.html at `/`), the design-system folders it references beside
27
+ * them, and NOTHING of this engine. That last clause is verified rather than asserted —
28
+ * every written file is scanned, and an injected marker, an engine route or a page global
29
+ * fails the command. See scripts/lib/graduate.mjs.
30
+ *
31
+ * ⚠️ A CLONE IS NOT A BACKUP, and the difference matters enough that it is printed on
32
+ * every run. A publish carries what a visitor can fetch, so a clone recovers exactly that.
33
+ * It cannot recover what was never published: `context.md` and other research notes (which
34
+ * the publish whitelist deliberately excludes), anything under `.github/`, and whatever a
35
+ * `space.json` said beyond what the manifest records of it. `augur export` is the backup —
36
+ * it copies the store, including history. This copies the SOURCE.
37
+ *
38
+ * What it CAN recover has to be enough to publish again, or leaving is not free — it is an
39
+ * export with a nicer layout. `registry.json` (the overlay catalog the build refuses to run
40
+ * without), `prototype-status.json` (the status baseline every index bakes) and the skill's
41
+ * `skill.json` (its inventory and css prefixes) all used to be on the list above; a clone of
42
+ * a hosted workspace came out clean and the publish back died in the build, and a tree that
43
+ * got past that rebuilt into a different site. Every input the build READS ships with the
44
+ * publish now and comes back with the clone (`C-clone-publish-roundtrip`).
45
+ *
46
+ * HOW `pull` DECIDES. Three-way, against the same local cache `publish.mjs` already writes
47
+ * at ~/.config/augur/published/<host>/<space>.json:
48
+ *
49
+ * remote changed, local matches last-synced → fast-forward. Somebody else published;
50
+ * take it.
51
+ * remote changed, local ALSO changed → CONFLICT. Left untouched, reported, and
52
+ * never silently overwritten — that is the
53
+ * whole reason this is not `clone --force`.
54
+ * remote unchanged → nothing to do, whatever the local file says.
55
+ *
56
+ * A conflict is left ON DISK as the local version, because the local version is the one
57
+ * with somebody's unsaved thinking in it. The remote is one command away.
58
+ */
59
+ import path from "node:path";
60
+ import os from "node:os";
61
+ import { mkdir, writeFile, readFile, readdir } from "node:fs/promises";
62
+ import { existsSync, readFileSync, statSync } from "node:fs";
63
+ import { createHash } from "node:crypto";
64
+ import { target, apiClient } from "./lib/store.mjs";
65
+ import { materializePlan, synthesizeSpaceJson } from "./lib/materialize.mjs";
66
+ import { stripBuildDecorations } from "./lib/publish-conflict.mjs";
67
+ import {
68
+ resolveUnit, unitFilesFromManifest, skillDirsReferenced, skillFilesFromManifest,
69
+ rerootHtml, residualFindings, isText, isSkillInternal,
70
+ } from "./lib/graduate.mjs";
71
+ import { fetchMarks, marksOverlap, markLine } from "./lib/marks.mjs";
72
+
73
+ const C = { dim: "\x1b[2m", warn: "\x1b[33m", bad: "\x1b[31m", ok: "\x1b[32m", off: "\x1b[0m" };
74
+ const log = (m) => console.log(`\x1b[35m[clone]\x1b[0m ${m}`);
75
+ const die = (m) => { console.error(`\x1b[31m[clone] ${m}\x1b[0m`); process.exit(1); };
76
+
77
+ const argv = process.argv.slice(2);
78
+ const flag = (n) => argv.includes(n);
79
+ // A flag never eats the NEXT flag as its value: `--prototype --from x` means "--prototype
80
+ // with no value", which is the shape a person types when they do not yet know the name.
81
+ // Reading `--from` as the prototype's name answers a question nobody asked.
82
+ const opt = (n, d = null) => {
83
+ const i = argv.indexOf(n);
84
+ const v = i > -1 ? argv[i + 1] : null;
85
+ return v && !v.startsWith("--") ? v : d;
86
+ };
87
+ const MODE = process.env.AUGUR_CLONE_MODE || "clone"; // set by cli.mjs for `pull`
88
+ const DRY = flag("--dry-run");
89
+
90
+ const sha256 = (buf) => createHash("sha256").update(buf).digest("hex");
91
+
92
+ /** Every file under a tree, as repo-relative paths. */
93
+ async function walk(dir, base = dir, acc = []) {
94
+ let entries;
95
+ try { entries = await readdir(dir, { withFileTypes: true }); } catch { return acc; }
96
+ for (const e of entries) {
97
+ if (e.name === ".git" || e.name === "node_modules") continue;
98
+ const abs = path.join(dir, e.name);
99
+ if (e.isDirectory()) await walk(abs, base, acc);
100
+ else acc.push(path.relative(base, abs).split(path.sep).join("/"));
101
+ }
102
+ return acc;
103
+ }
104
+
105
+ // ── one prototype, standalone ────────────────────────────────────────────────
106
+
107
+ /**
108
+ * The prototype folders a space TREE holds, written as publicPrefixes.
109
+ *
110
+ * Not a second vocabulary: expressing the tree in the same terms the manifest uses is what
111
+ * lets ONE resolver, one set of error messages and one plan serve both sources. A
112
+ * repo-backed workspace and a hosted one that never had a repo are the two shapes that
113
+ * exist, and a doorway only one of them fits is not a doorway.
114
+ */
115
+ async function treePrefixes(root) {
116
+ const out = [];
117
+ let top = [];
118
+ try { top = await readdir(root, { withFileTypes: true }); } catch (e) { return out; }
119
+ for (const e of top) {
120
+ if (!e.isDirectory() || e.name.startsWith(".") || e.name === "node_modules" || e.name === "skills") continue;
121
+ let protos = [];
122
+ try { protos = await readdir(path.join(root, e.name, "prototypes"), { withFileTypes: true }); } catch (err) { continue; }
123
+ for (const p of protos) if (p.isDirectory()) out.push(`/${e.name}/${p.name}/`);
124
+ }
125
+ return out.sort();
126
+ }
127
+
128
+ async function graduate() {
129
+ if (MODE === "pull") {
130
+ die("`augur pull` syncs a whole tree. To take one prototype out of it, run `augur clone --prototype <name>`.");
131
+ }
132
+ const from = opt("--from");
133
+ const unitArg = opt("--prototype");
134
+
135
+ // Where the bytes come from. A repo-backed workspace can graduate with no credentials
136
+ // and no network at all; a hosted one reads the same prototype out of the live store.
137
+ let prefixes, sourceHost = "", read, manifest = null, origin = "", spaceId = opt("--space"), root = "";
138
+ if (from) {
139
+ root = path.resolve(from);
140
+ if (!existsSync(path.join(root, "space.json"))) die(`${root} is not a workspace folder — no space.json in it.`);
141
+ try { sourceHost = new URL(JSON.parse(readFileSync(path.join(root, "space.json"), "utf8")).siteOrigin || "http://x.invalid").host; } catch (e) {}
142
+ if (sourceHost === "x.invalid") sourceHost = "";
143
+ prefixes = await treePrefixes(root);
144
+ read = async (f) => readFileSync(path.join(root, f.src));
145
+ } else {
146
+ const t = target({ needToken: true });
147
+ origin = t.origin;
148
+ const req = apiClient(origin, t.token);
149
+ sourceHost = new URL(origin).host;
150
+ if (!spaceId) die("name the workspace: augur clone --prototype <name> --space <id> (or --from <space-dir> to graduate straight out of a repo)");
151
+ try { manifest = await (await req(`${spaceId}/manifest`)).json(); }
152
+ catch (e) { die(`could not read ${spaceId}'s manifest — ${e.message}`); }
153
+ prefixes = (manifest.routing && manifest.routing.publicPrefixes) || [];
154
+ read = async (f) => {
155
+ const body = Buffer.from(await (await req(`${spaceId}/blob/${f.h}`)).arrayBuffer());
156
+ const got = sha256(body);
157
+ if (got !== f.h) die(`${f.url}: the store returned bytes whose hash is ${got.slice(0, 12)}, not ${String(f.h).slice(0, 12)}. Refusing to write content that is not what the manifest names.`);
158
+ return body;
159
+ };
160
+ }
161
+
162
+ // `--prototype` with nothing after it asks the question a person actually starts with:
163
+ // which ones are there? Nothing else lists them — the first cold run of this command
164
+ // went and read the directory tree by hand to pick one, which is a doorway with no sign
165
+ // on it.
166
+ if (!unitArg) {
167
+ if (!prefixes.length) die("this workspace publishes no prototypes.");
168
+ log(`${prefixes.length} prototype(s) to graduate from:`);
169
+ for (const p of prefixes) console.log(` ${p}`);
170
+ console.log(`\n${C.dim}Take one out with --prototype <name>. Add --dry-run to see what would come with it.${C.off}`);
171
+ process.exit(0);
172
+ }
173
+
174
+ let unit;
175
+ try { unit = resolveUnit(unitArg, prefixes); } catch (e) { die(e.message); }
176
+ const segs = unit.prefix.replace(/^\/+|\/+$/g, "").split("/");
177
+ const out = path.resolve(opt("--out") || path.join(process.cwd(), unit.name));
178
+
179
+ // The prototype's own files, re-rooted: what lived at /<project>/<name>/ now lives at /.
180
+ let plan;
181
+ if (from) {
182
+ const dir = path.join(root, segs[0], "prototypes", segs[1]);
183
+ plan = (await walk(dir)).sort().map((rel) => ({ out: rel, src: path.join(segs[0], "prototypes", segs[1], rel) }));
184
+ } else {
185
+ plan = unitFilesFromManifest(manifest, unit.prefix);
186
+ }
187
+ if (!plan.length) die(`${unit.prefix} has no files. Nothing to graduate.`);
188
+
189
+ const files = [];
190
+ for (const f of plan) {
191
+ let body = await read(f);
192
+ if (f.out.endsWith(".html")) body = Buffer.from(rerootHtml(body.toString("utf8"), f.out), "utf8");
193
+ files.push({ out: f.out, body });
194
+ }
195
+
196
+ // The design system comes too. A prototype is self-contained HTML except for the one
197
+ // thing it deliberately shares, and left behind it is an unstyled page on somebody's
198
+ // domain. Whole folders, not the files a scan saw: a stylesheet reaches its own fonts by
199
+ // paths no reference scan finds.
200
+ const dirs = skillDirsReferenced(files.filter((f) => isText(f.out)).map((f) => f.body.toString("utf8")));
201
+ let skillPlan = [];
202
+ if (dirs.length) {
203
+ if (from) {
204
+ for (const d of dirs) {
205
+ for (const rel of (await walk(path.join(root, "skills", d))).sort()) {
206
+ if (isSkillInternal(rel)) continue;
207
+ skillPlan.push({ out: `skills/${d}/${rel}`, src: path.join("skills", d, rel) });
208
+ }
209
+ }
210
+ } else {
211
+ skillPlan = skillFilesFromManifest(manifest, dirs);
212
+ }
213
+ }
214
+ for (const f of skillPlan) {
215
+ let body = await read(f);
216
+ if (f.out.endsWith(".html")) body = Buffer.from(rerootHtml(body.toString("utf8"), f.out), "utf8");
217
+ files.push({ out: f.out, body });
218
+ }
219
+
220
+ // THE PROOF, and it runs before anything is written so --dry-run gives the whole verdict.
221
+ const present = new Set(files.map((f) => f.out));
222
+ const texts = files.filter((f) => isText(f.out)).map((f) => ({ path: f.out, text: f.body.toString("utf8") }));
223
+ const findings = residualFindings(texts, present, { sourceHost });
224
+ const fatal = findings.filter((f) => f.level === "fatal");
225
+ const suspect = findings.filter((f) => f.level === "suspect");
226
+ const dangling = findings.filter((f) => f.level === "dangling");
227
+ const external = findings.filter((f) => f.level === "external");
228
+
229
+ const bytes = files.reduce((n, f) => n + f.body.length, 0);
230
+ log(`${unit.prefix} → ${files.length} file(s), ${(bytes / 1e6).toFixed(2)} MB${dirs.length ? `, design system: ${dirs.join(" ")}` : ", no design system referenced"}`);
231
+
232
+ for (const f of fatal) console.log(` ${C.bad}engine${C.off} ${f.path}:${f.line} ${f.why}\n ${C.dim}${f.ref}${C.off}`);
233
+ for (const f of dangling) console.log(` ${C.warn}dangling${C.off} ${f.path}:${f.line} ${f.ref} ${C.dim}${f.why}${C.off}`);
234
+ // Capped: the case that produces these produces them in bulk (a page whose subject IS
235
+ // the platform), and a thousand lines of prose scrolled past is the same as none.
236
+ for (const f of suspect.slice(0, 12)) console.log(` ${C.warn}mention${C.off} ${f.path}:${f.line} ${C.dim}${f.ref}${C.off}`);
237
+ if (suspect.length > 12) console.log(` ${C.dim}… and ${suspect.length - 12} more mention(s)${C.off}`);
238
+ for (const f of external) console.log(` ${C.dim}external ${f.path}:${f.line} ${f.ref}${C.off}`);
239
+
240
+ // Say the verdict out loud, including when it is all zeroes. The first cold run of this
241
+ // read a clean result as "the tool printed nothing" and said so: a person checking
242
+ // "carries no dependency on the engine" needs the check to have SPOKEN, not to have been
243
+ // quiet. Silence is what a broken checker also produces.
244
+ log(`checked ${texts.length} text file(s) of ${files.length}: ${fatal.length} engine, ${suspect.length} mention, ${dangling.length} dangling, ${external.length} external`);
245
+
246
+ // Refuse BEFORE writing. A folder that still fetches from the instance looks perfect on
247
+ // the old origin, and the person moving it has usually pointed a domain at it before
248
+ // anyone finds out — so it must not exist on disk to be copied somewhere by mistake.
249
+ // --dry-run is how you look at one without producing it.
250
+ if (fatal.length) {
251
+ log(`${C.bad}${fatal.length} reference(s) still reach this engine — nothing was written.${C.off}`);
252
+ console.log(`${C.dim}Each one is listed above with its file and line. They are the whole difference between a copy that serves anywhere and one that quietly needs this instance to stay up.${C.off}`);
253
+ process.exit(1);
254
+ }
255
+
256
+ if (DRY) {
257
+ log(`${C.dim}dry run — would write ${files.length} file(s) to ${out}${C.off}`);
258
+ } else {
259
+ if (existsSync(out) && (await readdir(out).catch(() => [])).length) {
260
+ die(`${out} already has something in it. Graduating writes a whole site; name an empty --out.`);
261
+ }
262
+ for (const f of files) {
263
+ const abs = path.join(out, f.out);
264
+ await mkdir(path.dirname(abs), { recursive: true });
265
+ await writeFile(abs, f.body);
266
+ }
267
+ log(`${C.ok}graduated ${files.length} file(s) → ${out}${C.off}`);
268
+ }
269
+
270
+ if (dangling.length) {
271
+ log(`${C.warn}${dangling.length} reference(s) resolve to nothing in the folder.${C.off} ${C.dim}The old site answered them; a domain serving only this folder will 404 them. Links to sibling prototypes are the usual cause — they did not come along.${C.off}`);
272
+ }
273
+ if (suspect.length) {
274
+ log(`${C.warn}${suspect.length} engine-shaped mention(s) in the text.${C.off} ${C.dim}Nothing fetches them or they would be listed above as engine. A page whose SUBJECT is the platform produces these by the hundred and depends on none of them — but a request built inside a script looks the same from here, so they are yours to glance at.${C.off}`);
275
+ }
276
+ console.log(
277
+ `\n${C.dim}Nothing in this folder fetches anything from Augur. Serve it with any static file server:\n` +
278
+ ` cd ${out} && python3 -m http.server 8080\n` +
279
+ `Putting it on a domain, and when a tool is ready to leave at all: docs/graduation.md${C.off}`
280
+ );
281
+ process.exit(dangling.length || suspect.length ? 3 : 0);
282
+ }
283
+
284
+ async function main() {
285
+ if (flag("--prototype")) return graduate();
286
+ const { origin, token } = target({ needToken: true });
287
+ const req = apiClient(origin, token);
288
+
289
+ // `pull` runs from inside a cloned tree, so the space is whatever that tree says it is.
290
+ let spaceId = opt("--space");
291
+ let out = opt("--out");
292
+ if (MODE === "pull") {
293
+ out = out || process.cwd();
294
+ const sj = path.join(out, "space.json");
295
+ if (!existsSync(sj)) die(`no space.json in ${out} — run \`augur pull\` from inside a cloned tree, or \`augur clone --space <id>\` first.`);
296
+ try { spaceId = spaceId || JSON.parse(readFileSync(sj, "utf8")).id; } catch (e) { die(`could not read ${sj}: ${e.message}`); }
297
+ }
298
+ if (!spaceId) die("name a space: augur clone --space <id> [--out <dir>]");
299
+ out = out || path.resolve(process.cwd(), spaceId);
300
+
301
+ let manifest;
302
+ try { manifest = await (await req(`${spaceId}/manifest`)).json(); }
303
+ catch (e) { die(`could not read ${spaceId}'s manifest — ${e.message}`); }
304
+
305
+ const { files, skipped } = materializePlan(manifest);
306
+ if (!files.length) die(`${spaceId} has no published source files. Nothing to clone.`);
307
+
308
+ // The last-synced state publish.mjs already records, so `pull` is three-way rather than
309
+ // last-writer-wins.
310
+ const cacheDir = path.join(os.homedir(), ".config", "augur", "published", new URL(origin).host);
311
+ const cachePath = path.join(cacheDir, spaceId + ".json");
312
+ let cached = null;
313
+ try { cached = JSON.parse(readFileSync(cachePath, "utf8")); } catch (e) {}
314
+ const lastSynced = (cached && cached.files) || {};
315
+
316
+ log(`${spaceId} v${manifest.version || 0} at ${origin} — ${files.length} source file(s), ${skipped.length} generated and skipped`);
317
+
318
+ const plan = { write: [], skip: [], conflict: [] };
319
+ for (const f of files) {
320
+ const abs = path.join(out, f.path);
321
+ if (!existsSync(abs)) { plan.write.push({ ...f, why: "new" }); continue; }
322
+ const local = sha256(readFileSync(abs));
323
+ if (local === f.h) { plan.skip.push({ ...f, why: "already identical" }); continue; }
324
+ // The URL is the key publish.mjs records under, not the source path.
325
+ const synced = lastSynced[f.url] && (lastSynced[f.url].h || lastSynced[f.url]);
326
+ if (MODE === "clone") { plan.write.push({ ...f, why: "overwriting (clone)" }); continue; }
327
+ if (synced && local === synced) { plan.write.push({ ...f, why: "fast-forward" }); continue; }
328
+ plan.conflict.push({ ...f, why: synced ? "changed on BOTH sides since last sync" : "changed locally, and never synced from here" });
329
+ }
330
+
331
+ for (const c of plan.conflict) {
332
+ console.log(` ${C.warn}conflict${C.off} ${c.path} ${C.dim}${c.why}${C.off}`);
333
+ }
334
+
335
+ // ── WHO IS ALREADY IN HERE (`F-presence-marks`) ───────────────────────────
336
+ //
337
+ // A pull is a work-start: files are about to be written into a tree somebody is about to
338
+ // edit. So the marks on the paths ABOUT TO BE TOUCHED are printed before the first byte
339
+ // lands, which is the only moment the information can still change what happens next.
340
+ //
341
+ // ⚠️ IT NEVER REFUSES AND NEVER CHANGES THE EXIT CODE. A mark is a note, not a lock, and
342
+ // a pull that stopped for one would be a lock with extra steps. Read on a URL rather than
343
+ // a source path: a mark names the published unit, which is what both sides can agree on.
344
+ const touching = [...plan.write, ...plan.conflict];
345
+ if (touching.length) {
346
+ const marks = (await fetchMarks(req))
347
+ .filter((m) => touching.some((f) => marksOverlap(f.url, m.path)));
348
+ if (marks.length) {
349
+ console.log(`\n ${C.warn}somebody is working on paths this pull touches${C.off}`);
350
+ for (const m of marks) console.log(` ${markLine(m)}`);
351
+ console.log(` ${C.dim}Nothing here is blocked. It is worth knowing before you edit the same folder.${C.off}\n`);
352
+ }
353
+ }
354
+ if (DRY) {
355
+ log(`${C.dim}dry run — would write ${plan.write.length}, leave ${plan.skip.length} identical, ${plan.conflict.length} conflict(s)${C.off}`);
356
+ process.exit(plan.conflict.length ? 2 : 0);
357
+ }
358
+
359
+ let wrote = 0, bytes = 0, peeled = 0;
360
+ for (const f of plan.write) {
361
+ const body = Buffer.from(await (await req(`${spaceId}/blob/${f.h}`)).arrayBuffer());
362
+ // Hash the bytes the store returned, BEFORE peeling: the manifest names the published
363
+ // content, and a store that answered with something else is the failure worth stopping
364
+ // on. Peeling afterwards is a local transform, not a trust decision.
365
+ const got = sha256(body);
366
+ if (got !== f.h) die(`${f.url}: the store returned bytes whose hash is ${got.slice(0, 12)}, not ${String(f.h).slice(0, 12)}. Refusing to write content that is not what the manifest names.`);
367
+
368
+ // A PUBLISHED PAGE IS NOT ITS SOURCE, and writing it as though it were is how a clone
369
+ // stops being republishable. The build decorates authored HTML on the way out: og and
370
+ // twitter meta, the linked-assets stamp, an emoji on the <title>, marker-wrapped
371
+ // review and companion scripts, and a rewritten depth on every skills/ path. The
372
+ // marker regions would survive a republish idempotently; the meta would not, so a
373
+ // clone-publish-clone cycle would accumulate it. `stripBuildDecorations` is the peel
374
+ // the publish adopt path already uses for exactly this, including putting the skills/
375
+ // depth back to what the file's REPO location needs.
376
+ let outBody = body;
377
+ if (f.path.endsWith(".html")) {
378
+ const text = body.toString("utf8");
379
+ const relDir = path.dirname(f.path) === "." ? "" : path.dirname(f.path);
380
+ // The peel leaves the blank line the removed block sat on. Harmless — the adopt
381
+ // path compares through a whitespace-collapsing comparator — but a clone is read by
382
+ // a person and diffed against a repo, so close the gap it leaves rather than
383
+ // shipping 18 files that differ from their source by an empty line. Only immediately
384
+ // before the two closing tags the build writes at; authored blank lines are not ours.
385
+ const peeledText = stripBuildDecorations(text, relDir)
386
+ .replace(/\n[ \t]*\n(\s*<\/head>)/g, "\n$1")
387
+ .replace(/\n[ \t]*\n(\s*<\/body>)/g, "\n$1");
388
+ if (peeledText !== text) peeled++;
389
+ outBody = Buffer.from(peeledText, "utf8");
390
+ }
391
+
392
+ const abs = path.join(out, f.path);
393
+ await mkdir(path.dirname(abs), { recursive: true });
394
+ await writeFile(abs, outBody);
395
+ wrote++; bytes += outBody.length;
396
+ }
397
+
398
+ // space.json is not a served asset and is in no manifest as a file, so a clone synthesizes
399
+ // it from the record the build stamps on every manifest (`manifest.space`: the name, the
400
+ // projects label, the help sections — what the generated pages were rendered with) — and
401
+ // never overwrites one that already exists, because a real tree's space.json can carry
402
+ // decisions the manifest does not record.
403
+ const sj = path.join(out, "space.json");
404
+ if (!existsSync(sj)) {
405
+ const synth = synthesizeSpaceJson(spaceId, origin, manifest.space);
406
+ await writeFile(sj, JSON.stringify(synth, null, 2) + "\n");
407
+ log(`wrote space.json from the manifest's own record of the space (${Object.keys(synth).join(", ")}) — nothing else about it is recoverable from a publish`);
408
+ }
409
+
410
+ log(`${C.ok}${MODE === "pull" ? "pulled" : "cloned"} ${wrote} file(s), ${(bytes / 1e6).toFixed(2)} MB → ${out}${C.off}`);
411
+ if (peeled) log(`${C.dim}${peeled} page(s) peeled back to source: the build meta, the marker chrome and the depth rewrites removed${C.off}`);
412
+ if (plan.skip.length) log(`${C.dim}${plan.skip.length} already identical${C.off}`);
413
+ if (plan.conflict.length) {
414
+ log(`${C.warn}${plan.conflict.length} file(s) left untouched because they changed on both sides. Yours are still on disk; the live copy is one \`augur clone --out <elsewhere>\` away.${C.off}`);
415
+ }
416
+
417
+ // Said every time, because somebody will otherwise use this as a backup.
418
+ console.log(`\n${C.dim}A clone is what a visitor could fetch, not the repository. It cannot recover research notes (context.md and friends) or anything under .github/ — a publish never carried them. \`augur export\` is the backup.${C.off}`);
419
+ process.exit(plan.conflict.length ? 2 : 0);
420
+ }
421
+
422
+ main().catch((e) => die(e && e.stack ? e.stack : String(e)));
@@ -0,0 +1,30 @@
1
+ #!/usr/bin/env node
2
+ // augur close [--discard] — remove this draft folder. A landed draft closes freely; an open
3
+ // one is kept unless --discard, which also abandons the draft on the instance.
4
+ import { resolveOrigin, resolveToken } from "./lib/store.mjs";
5
+ import { readState, unitClient, doClose } from "./lib/draft.mjs";
6
+
7
+ const log = (m) => console.error(`\x1b[35m[close]\x1b[0m ${m}`);
8
+ const die = (m) => { console.error(`\x1b[31m[close]\x1b[0m ${m}`); process.exit(1); };
9
+ const discard = process.argv.includes("--discard");
10
+ const dir = process.cwd();
11
+ const st = readState(dir);
12
+ if (!st) {
13
+ // A read-only copy has no state file; doClose recognises it from the registry.
14
+ process.chdir("..");
15
+ const r = await doClose({ client: {}, dir, discard: false });
16
+ if (!r.ok) die("not a draft folder — nothing to close here.");
17
+ log("read-only copy removed");
18
+ process.exit(0);
19
+ }
20
+ const origin = st.origin || resolveOrigin();
21
+ const token = resolveToken(origin);
22
+ if (!token && !st.landed) die("no publish token — run `augur connect` once.");
23
+ const client = unitClient({ origin, token, space: st.space, session: st.session });
24
+ process.chdir("..");
25
+ const r = await doClose({ client, dir, discard });
26
+ if (!r.ok) {
27
+ if (r.error === "draft-still-open") die(`draft ${r.draftId} has not landed — \`augur land\` first, or \`augur close --discard\` to abandon it (it stays on the instance for a while).`);
28
+ die(`close refused: ${r.error || r.status}`);
29
+ }
30
+ log(r.discarded ? "draft abandoned and folder removed" : "folder removed");
@@ -0,0 +1,103 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * augur connect — pair this machine with a browser you are already signed in to.
4
+ *
5
+ * `C-cli-connect-device-flow`. `augur login` asks for an email and a password, which puts
6
+ * a human credential into a terminal, a shell history, and quite possibly an agent
7
+ * transcript. This asks for neither: it prints a code, you type that code into a browser
8
+ * that already has your session, and the token approval mints comes back here.
9
+ *
10
+ * npx augur connect [--origin https://your.site]
11
+ *
12
+ * `augur login` stays for CI and scripts, where there is no browser to type into.
13
+ *
14
+ * WHAT THIS PROCESS NEVER HOLDS: your password. What it does hold, briefly, is a device
15
+ * secret that authorises collecting the token — printed nowhere, kept in memory, and dead
16
+ * five minutes after `start`.
17
+ */
18
+ import path from "node:path";
19
+ import os from "node:os";
20
+ import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
21
+ import { resolveOrigin } from "./lib/store.mjs";
22
+
23
+ const C = { dim: "\x1b[2m", bold: "\x1b[1m", ok: "\x1b[32m", warn: "\x1b[33m", off: "\x1b[0m" };
24
+ const log = (m) => console.log(`\x1b[35m[connect]\x1b[0m ${m}`);
25
+ const die = (m) => { console.error(`\x1b[31m[connect] ${m}\x1b[0m`); process.exit(1); };
26
+
27
+ const argv = process.argv.slice(2);
28
+ const opt = (n, d = null) => { const i = argv.indexOf(n); return i > -1 && argv[i + 1] ? argv[i + 1] : d; };
29
+ const ORIGIN = (opt("--origin") || process.env.AUGUR_ORIGIN || resolveOrigin() || "").replace(/\/$/, "");
30
+ if (!ORIGIN) die("no origin — pass --origin https://your.site, or set AUGUR_ORIGIN.");
31
+
32
+ const POLL_MS = 2000;
33
+
34
+ async function post(pathPart, body) {
35
+ const r = await fetch(`${ORIGIN}/__publish/_pair/${pathPart}`, {
36
+ method: "POST",
37
+ headers: { "content-type": "application/json" },
38
+ body: JSON.stringify(body || {}),
39
+ });
40
+ let json = null;
41
+ try { json = await r.json(); } catch (e) {}
42
+ return { status: r.status, json };
43
+ }
44
+
45
+ const start = await post("start");
46
+ if (start.status === 429) die("too many attempts from here. Wait a few minutes.");
47
+ if (start.status !== 200 || !start.json || !start.json.code) {
48
+ // The routes answer as though they are not there when the instance has not opted in,
49
+ // so this is the message that has to name the setting rather than the status code.
50
+ die(`this instance has not switched device pairing on.\n`
51
+ + ` Add "devicePairing": true to its deploy.config.json and redeploy, or use \`augur login\`.\n`
52
+ + ` (${ORIGIN} answered ${start.status})`);
53
+ }
54
+
55
+ const { code, deviceSecret, approveUrl, expiresInMs } = start.json;
56
+ const mins = Math.round((expiresInMs || 300000) / 60000);
57
+ const pretty = code.length === 8 ? `${code.slice(0, 4)}-${code.slice(4)}` : code;
58
+
59
+ // Written to be RELAYED, not read: the usual runner of this command is an agent, which
60
+ // pastes the first line to a person. So the first line is the whole instruction, and it
61
+ // names who has to act — the owner of the workspace, in a browser they are already in.
62
+ console.log("");
63
+ console.log(` Ask the owner of this workspace to open ${C.bold}${approveUrl}${C.off} and enter ${C.bold}${pretty}${C.off}.`);
64
+ console.log("");
65
+ console.log(` ${C.dim}Send them that line as it is. They open it in a browser they are already signed in`);
66
+ console.log(` to; nothing is typed here, and nobody is asked for a password.`);
67
+ console.log(` The code is good for ${mins} minutes and only for this terminal. Waiting…${C.off}`);
68
+ console.log("");
69
+ console.log(` ${C.warn}If you did not just run this command, do not approve it.${C.off}`);
70
+ console.log("");
71
+
72
+ const deadline = Date.now() + (expiresInMs || 300000);
73
+ let saved = null;
74
+ while (Date.now() < deadline) {
75
+ await new Promise((r) => setTimeout(r, POLL_MS));
76
+ const res = await post("claim", { code, deviceSecret });
77
+ if (res.status === 200 && res.json && res.json.token) { saved = res.json; break; }
78
+ if (res.status === 202) continue; // still waiting for somebody to approve
79
+ if (res.status === 404) die("this pairing is no longer valid. Run `augur connect` again.");
80
+ }
81
+ if (!saved) die(`nobody approved it within ${mins} minutes. Run \`augur connect\` again.`);
82
+
83
+ const dir = path.join(os.homedir(), ".config", "augur");
84
+ mkdirSync(dir, { recursive: true });
85
+ const file = path.join(dir, "tokens.json");
86
+ let all = {};
87
+ try { all = JSON.parse(readFileSync(file, "utf8")); } catch (e) {}
88
+ // Same shape `augur login` writes, so publish/ship/status find it with no second lookup.
89
+ // `via` is the one addition: a token nobody can account for is a token nobody revokes.
90
+ all[new URL(ORIGIN).host] = {
91
+ token: saved.token, space: saved.space, via: "connect", at: new Date().toISOString(),
92
+ ...(saved.expiresAt ? { expiresAt: saved.expiresAt } : {}),
93
+ };
94
+ writeFileSync(file, JSON.stringify(all, null, 2), { mode: 0o600 });
95
+
96
+ log(`${C.ok}paired — publish access: ${saved.space === "*" ? "all spaces" : saved.space}${C.off}`);
97
+ console.log(`ready — \`augur publish\` will now use this token for ${ORIGIN}`);
98
+ if (saved.expiresAt) {
99
+ const days = Math.max(0, Math.round((Date.parse(saved.expiresAt) - Date.now()) / 86400000));
100
+ console.log(`${C.dim}It expires in ${days} days (${saved.expiresAt.slice(0, 10)}). Run \`augur connect\` again then.${C.off}`);
101
+ } else {
102
+ console.log(`${C.dim}It expires on its own. Run \`augur connect\` again when it does.${C.off}`);
103
+ }