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 +9 -1
- package/dist/config.js +56 -10
- package/dist/index.js +1 -1
- package/dist/util.js +73 -24
- package/package.json +1 -1
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.
|
|
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
|
|
89
|
-
*
|
|
90
|
-
* space
|
|
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
|
|
94
|
-
|
|
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
|
|
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.
|
|
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 {
|
|
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
|
-
|
|
94
|
-
|
|
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
|
|
98
|
-
*
|
|
99
|
-
*
|
|
100
|
-
*
|
|
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(
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
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
|
+
}
|