protocontent 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -72,7 +72,15 @@ Each running bridge process owns one **space** — a DNS-safe id like
72
72
  lifetime. When `CLAUDE_SESSION_ID` is present the id is derived deterministically
73
73
  from it; otherwise it's random. A `spaceLabel` is derived from the current working
74
74
  directory's basename. Everything you publish in a session lands in the same space,
75
- served at `https://<spaceId>.protocontent.com`, which updates live.
75
+ served at `https://<spaceId>.protocontent.app`, which updates live.
76
+
77
+ ### Keeping artifacts out of git
78
+
79
+ protocontent artifacts are **ephemeral** — you publish them to a URL, you don't
80
+ commit them. On startup the bridge makes a best-effort, idempotent check: if it's
81
+ running inside a git repo, it ensures `.protocontent/` is in your `.gitignore`.
82
+ Stage anything you publish under `.protocontent/` and it stays out of version
83
+ control automatically. Opt out with `PROTOCONTENT_NO_GITIGNORE=1`.
76
84
 
77
85
  ## Tools
78
86
 
package/dist/config.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import { promises as fs } from "node:fs";
2
2
  import * as os from "node:os";
3
3
  import * as path from "node:path";
4
- import { generateSpaceId, slugify } from "./util.js";
4
+ import { generateSpaceId, slugify, ensureGitignore } from "./util.js";
5
5
  export const DEFAULT_API_BASE = "https://api.protocontent.com";
6
6
  /** Resolve the configured API base, trimming any trailing slash. */
7
7
  export function getApiBase() {
@@ -84,18 +84,62 @@ export async function resolveToken(apiBase) {
84
84
  const minted = await mintAnonymousToken(apiBase);
85
85
  return minted.token;
86
86
  }
87
+ function spacesPath() {
88
+ return path.join(configDir(), "spaces.json");
89
+ }
90
+ async function readSpaces() {
91
+ try {
92
+ const raw = await fs.readFile(spacesPath(), "utf8");
93
+ const parsed = JSON.parse(raw);
94
+ return parsed && typeof parsed === "object" ? parsed : {};
95
+ }
96
+ catch {
97
+ return {};
98
+ }
99
+ }
100
+ async function writeSpaces(map) {
101
+ await fs.mkdir(configDir(), { recursive: true, mode: 0o700 });
102
+ await fs.writeFile(spacesPath(), JSON.stringify(map, null, 2) + "\n", { mode: 0o600 });
103
+ try {
104
+ await fs.chmod(spacesPath(), 0o600);
105
+ }
106
+ catch {
107
+ // best effort
108
+ }
109
+ }
110
+ // A valid high-entropy space id: two words + a >=20-char base32 suffix.
111
+ const NEW_SPACE_ID = /^[a-z]+-[a-z]+-[a-z2-7]{20,}$/;
87
112
  /**
88
- * Compute the per-process space id and label.
89
- * Seeded deterministically from CLAUDE_SESSION_ID when present so the
90
- * space lines up with the agent's thread; otherwise random.
113
+ * Compute the space id + label for this run.
114
+ *
115
+ * The space id is HIGH-ENTROPY RANDOM (~110 bits) — it's the capability that
116
+ * grants access to a space, so it must be unguessable and is NOT derived from
117
+ * anything public like the agent session id. For stability across bridge
118
+ * restarts within the SAME agent thread, the random id is cached keyed by
119
+ * CLAUDE_SESSION_ID in ~/.protocontent/spaces.json. Without a session id, each
120
+ * process gets a fresh random space.
91
121
  */
92
- export function computeSpace() {
93
- const seed = process.env.CLAUDE_SESSION_ID?.trim();
94
- const spaceId = generateSpaceId(seed && seed.length > 0 ? seed : undefined);
122
+ export async function computeSpace() {
123
+ const sessionKey = process.env.CLAUDE_SESSION_ID?.trim();
124
+ let spaceId;
125
+ if (sessionKey && sessionKey.length > 0) {
126
+ const map = await readSpaces();
127
+ const cached = map[sessionKey];
128
+ if (cached && NEW_SPACE_ID.test(cached)) {
129
+ spaceId = cached;
130
+ }
131
+ else {
132
+ spaceId = generateSpaceId();
133
+ map[sessionKey] = spaceId;
134
+ await writeSpaces(map);
135
+ }
136
+ }
137
+ else {
138
+ spaceId = generateSpaceId();
139
+ }
95
140
  let spaceLabel;
96
141
  try {
97
- const base = path.basename(process.cwd());
98
- const label = slugify(base, "");
142
+ const label = slugify(path.basename(process.cwd()), "");
99
143
  spaceLabel = label.length > 0 ? label : undefined;
100
144
  }
101
145
  catch {
@@ -107,6 +151,8 @@ export function computeSpace() {
107
151
  export async function loadConfig() {
108
152
  const apiBase = getApiBase();
109
153
  const token = await resolveToken(apiBase);
110
- const { spaceId, spaceLabel } = computeSpace();
154
+ const { spaceId, spaceLabel } = await computeSpace();
155
+ // Keep ephemeral published artifacts out of git (best-effort, idempotent).
156
+ await ensureGitignore(process.env.CLAUDE_PROJECT_DIR || process.cwd());
111
157
  return { apiBase, token, spaceId, spaceLabel };
112
158
  }
package/dist/index.js CHANGED
@@ -239,7 +239,7 @@ async function main() {
239
239
  }, async () => {
240
240
  try {
241
241
  const res = await listSpace(config);
242
- const spaceUrl = `https://${config.spaceId}.protocontent.com`;
242
+ const spaceUrl = res.spaceUrl || `https://${config.spaceId}.protocontent.app`;
243
243
  if (!res.artifacts || res.artifacts.length === 0) {
244
244
  return textResult(`No artifacts published yet in space ${config.spaceId}.\n` +
245
245
  `Space: ${spaceUrl}`);
package/dist/util.js CHANGED
@@ -1,4 +1,4 @@
1
- import { createHash } from "node:crypto";
1
+ import { randomBytes, randomInt } from "node:crypto";
2
2
  import { promises as fs } from "node:fs";
3
3
  import * as path from "node:path";
4
4
  /**
@@ -90,32 +90,28 @@ const NOUNS = [
90
90
  "comet", "ember", "falcon", "heron", "lynx", "otter", "raven", "sparrow",
91
91
  "beacon", "compass", "lantern", "anchor", "pebble", "ripple", "breeze", "echo",
92
92
  ];
93
- function pick(arr, index) {
94
- return arr[index % arr.length];
93
+ const B32 = "abcdefghijklmnopqrstuvwxyz234567";
94
+ /** A cryptographically-random DNS-safe token of `len` base32 chars (~5 bits each). */
95
+ function randomToken(len) {
96
+ const bytes = randomBytes(len);
97
+ let out = "";
98
+ for (let i = 0; i < len; i++)
99
+ out += B32[bytes[i] & 31];
100
+ return out;
95
101
  }
96
102
  /**
97
- * Generate a DNS-safe space id of the form `word-word-xxx`
98
- * (e.g. `amber-canyon-7f3`). When `seed` is provided the result is
99
- * deterministic (so it lines up with an agent thread/session id);
100
- * otherwise it is random.
103
+ * Generate a DNS-safe space id like `quiet-harbor-3kf9q…` — two random words
104
+ * for a little readability, plus a 22-char crypto-random suffix (~110 bits).
105
+ *
106
+ * The id is the capability that grants access to a space, so it must be
107
+ * UNGUESSABLE and is never derived from anything public (e.g. the agent session
108
+ * id). Per-thread stability is handled by caching this random id in
109
+ * ~/.protocontent/spaces.json (see config.ts), NOT by seeding it.
101
110
  */
102
- export function generateSpaceId(seed) {
103
- if (seed && seed.length > 0) {
104
- const hash = createHash("sha256").update(seed).digest();
105
- const adjIndex = hash.readUInt16BE(0);
106
- const nounIndex = hash.readUInt16BE(2);
107
- // 3-char base36 suffix derived from the next bytes.
108
- const suffixNum = hash.readUInt32BE(4) % (36 * 36 * 36);
109
- const suffix = suffixNum.toString(36).padStart(3, "0").slice(-3);
110
- return `${pick(ADJECTIVES, adjIndex)}-${pick(NOUNS, nounIndex)}-${suffix}`;
111
- }
112
- const adj = ADJECTIVES[Math.floor(Math.random() * ADJECTIVES.length)];
113
- const noun = NOUNS[Math.floor(Math.random() * NOUNS.length)];
114
- const suffix = Math.floor(Math.random() * (36 * 36 * 36))
115
- .toString(36)
116
- .padStart(3, "0")
117
- .slice(-3);
118
- return `${adj}-${noun}-${suffix}`;
111
+ export function generateSpaceId() {
112
+ const adj = ADJECTIVES[randomInt(ADJECTIVES.length)];
113
+ const noun = NOUNS[randomInt(NOUNS.length)];
114
+ return `${adj}-${noun}-${randomToken(22)}`;
119
115
  }
120
116
  const DEFAULT_SKIP_DIRS = new Set([
121
117
  "node_modules",
@@ -179,3 +175,56 @@ export function formatBytes(bytes) {
179
175
  return `${(bytes / 1024).toFixed(0)} KB`;
180
176
  return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
181
177
  }
178
+ // --- gitignore convention ---------------------------------------------------
179
+ /**
180
+ * Best-effort: if `startDir` is inside a git repo, ensure `.protocontent/` is
181
+ * listed in the repo's .gitignore. protocontent artifacts are ephemeral — you
182
+ * publish them to a URL, you don't commit them — so staging them under
183
+ * `.protocontent/` keeps them out of version control automatically.
184
+ *
185
+ * Idempotent; silent on any failure; opt out with PROTOCONTENT_NO_GITIGNORE=1.
186
+ */
187
+ export async function ensureGitignore(startDir = process.cwd()) {
188
+ if (process.env.PROTOCONTENT_NO_GITIGNORE)
189
+ return;
190
+ try {
191
+ // Walk up to the repo root (a directory containing .git).
192
+ let dir = path.resolve(startDir);
193
+ let root = null;
194
+ for (let i = 0; i < 40; i++) {
195
+ try {
196
+ await fs.stat(path.join(dir, ".git"));
197
+ root = dir;
198
+ break;
199
+ }
200
+ catch {
201
+ const parent = path.dirname(dir);
202
+ if (parent === dir)
203
+ break;
204
+ dir = parent;
205
+ }
206
+ }
207
+ if (!root)
208
+ return; // not inside a git repo — nothing to do
209
+ const giPath = path.join(root, ".gitignore");
210
+ let content = "";
211
+ try {
212
+ content = await fs.readFile(giPath, "utf8");
213
+ }
214
+ catch {
215
+ // no .gitignore yet — appendFile will create it
216
+ }
217
+ const alreadyIgnored = content
218
+ .split(/\r?\n/)
219
+ .map((l) => l.trim())
220
+ .some((l) => l === ".protocontent/" || l === ".protocontent");
221
+ if (alreadyIgnored)
222
+ return;
223
+ const sep = content.length > 0 && !content.endsWith("\n") ? "\n" : "";
224
+ const block = `${sep}\n# protocontent — ephemeral published artifacts (not source)\n.protocontent/\n`;
225
+ await fs.appendFile(giPath, block);
226
+ }
227
+ catch {
228
+ // best effort — never fail a publish over this
229
+ }
230
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "protocontent",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "Co-located stdio MCP bridge for protocontent — read local files and publish them to the protocontent HTTP API.",
5
5
  "type": "module",
6
6
  "bin": {