protocontent 0.1.0 → 0.3.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 +128 -10
- package/dist/index.js +62 -51
- 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,134 @@ 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
|
-
*
|
|
89
|
-
*
|
|
90
|
-
*
|
|
113
|
+
* Resolve the stable PROJECT ROOT for a directory: the top of its git repo,
|
|
114
|
+
* shared across all worktrees of that repo, so the space stays the same whether
|
|
115
|
+
* you run in the main checkout or any worktree.
|
|
116
|
+
*
|
|
117
|
+
* Walks up looking for `.git`. A `.git` directory means we found the repo root.
|
|
118
|
+
* A `.git` *file* means this is a linked worktree — it points at
|
|
119
|
+
* `<mainRoot>/.git/worktrees/<name>`, so we recover `<mainRoot>` from it.
|
|
120
|
+
* Returns null if `startDir` is not inside a git repo.
|
|
91
121
|
*/
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
122
|
+
async function resolveProjectRoot(startDir) {
|
|
123
|
+
let dir = path.resolve(startDir);
|
|
124
|
+
for (let i = 0; i < 40; i++) {
|
|
125
|
+
const gitPath = path.join(dir, ".git");
|
|
126
|
+
try {
|
|
127
|
+
const stat = await fs.stat(gitPath);
|
|
128
|
+
if (stat.isDirectory())
|
|
129
|
+
return dir;
|
|
130
|
+
if (stat.isFile()) {
|
|
131
|
+
// Linked worktree: ".git" is a file like "gitdir: /abs/.git/worktrees/x".
|
|
132
|
+
try {
|
|
133
|
+
const content = await fs.readFile(gitPath, "utf8");
|
|
134
|
+
const m = content.match(/gitdir:\s*(.+)\s*$/m);
|
|
135
|
+
if (m) {
|
|
136
|
+
let gd = m[1].trim();
|
|
137
|
+
if (!path.isAbsolute(gd))
|
|
138
|
+
gd = path.resolve(dir, gd);
|
|
139
|
+
const marker = `${path.sep}.git${path.sep}worktrees${path.sep}`;
|
|
140
|
+
const idx = gd.indexOf(marker);
|
|
141
|
+
if (idx >= 0)
|
|
142
|
+
return gd.slice(0, idx); // the main repo root
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
catch {
|
|
146
|
+
// fall through to using this dir
|
|
147
|
+
}
|
|
148
|
+
return dir;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
catch {
|
|
152
|
+
// no .git here — keep walking up
|
|
153
|
+
}
|
|
154
|
+
const parent = path.dirname(dir);
|
|
155
|
+
if (parent === dir)
|
|
156
|
+
break;
|
|
157
|
+
dir = parent;
|
|
158
|
+
}
|
|
159
|
+
return null;
|
|
160
|
+
}
|
|
161
|
+
/**
|
|
162
|
+
* Compute the space id + label for this run.
|
|
163
|
+
*
|
|
164
|
+
* The space id is HIGH-ENTROPY RANDOM (~110 bits) — it's the capability that
|
|
165
|
+
* grants access to a space, so it must be unguessable and is NEVER derived from
|
|
166
|
+
* anything public.
|
|
167
|
+
*
|
|
168
|
+
* It is cached so the SAME space is reused across bridge restarts, subagents,
|
|
169
|
+
* compaction, and resumed/renamed sessions — anything that would otherwise mint
|
|
170
|
+
* a fresh slug and orphan the links already shared. The cache key, in priority
|
|
171
|
+
* order:
|
|
172
|
+
* 1. PROTOCONTENT_SPACE_ID — explicit pin (advanced; used verbatim).
|
|
173
|
+
* 2. The git PROJECT ROOT (stable across worktrees of one repo).
|
|
174
|
+
* 3. CLAUDE_PROJECT_DIR, else the current working directory.
|
|
175
|
+
* The chosen id is persisted under that key in ~/.protocontent/spaces.json and a
|
|
176
|
+
* valid cached id is never silently regenerated. (Legacy entries keyed by
|
|
177
|
+
* CLAUDE_SESSION_ID are still honored and migrated to the project key.)
|
|
178
|
+
*/
|
|
179
|
+
export async function computeSpace() {
|
|
180
|
+
// 1. Explicit pin wins outright.
|
|
181
|
+
const pinned = process.env.PROTOCONTENT_SPACE_ID?.trim();
|
|
182
|
+
// 2/3. Resolve a stable project key.
|
|
183
|
+
const startDir = process.env.CLAUDE_PROJECT_DIR?.trim() || process.cwd();
|
|
184
|
+
const projectRoot = await resolveProjectRoot(startDir);
|
|
185
|
+
const projectKey = projectRoot || startDir;
|
|
186
|
+
let spaceId;
|
|
187
|
+
if (pinned && pinned.length > 0) {
|
|
188
|
+
spaceId = slugify(pinned, "");
|
|
189
|
+
if (!spaceId)
|
|
190
|
+
spaceId = generateSpaceId();
|
|
191
|
+
}
|
|
192
|
+
else {
|
|
193
|
+
const map = await readSpaces();
|
|
194
|
+
const sessionKey = process.env.CLAUDE_SESSION_ID?.trim();
|
|
195
|
+
const fromProject = map[projectKey];
|
|
196
|
+
const fromSession = sessionKey ? map[sessionKey] : undefined;
|
|
197
|
+
if (fromProject && NEW_SPACE_ID.test(fromProject)) {
|
|
198
|
+
spaceId = fromProject;
|
|
199
|
+
}
|
|
200
|
+
else if (fromSession && NEW_SPACE_ID.test(fromSession)) {
|
|
201
|
+
// Migrate a legacy session-keyed space onto the durable project key.
|
|
202
|
+
spaceId = fromSession;
|
|
203
|
+
map[projectKey] = spaceId;
|
|
204
|
+
await writeSpaces(map);
|
|
205
|
+
}
|
|
206
|
+
else {
|
|
207
|
+
spaceId = generateSpaceId();
|
|
208
|
+
map[projectKey] = spaceId;
|
|
209
|
+
await writeSpaces(map);
|
|
210
|
+
}
|
|
211
|
+
}
|
|
95
212
|
let spaceLabel;
|
|
96
213
|
try {
|
|
97
|
-
const
|
|
98
|
-
const label = slugify(base, "");
|
|
214
|
+
const label = slugify(path.basename(projectKey), "");
|
|
99
215
|
spaceLabel = label.length > 0 ? label : undefined;
|
|
100
216
|
}
|
|
101
217
|
catch {
|
|
@@ -107,6 +223,8 @@ export function computeSpace() {
|
|
|
107
223
|
export async function loadConfig() {
|
|
108
224
|
const apiBase = getApiBase();
|
|
109
225
|
const token = await resolveToken(apiBase);
|
|
110
|
-
const { spaceId, spaceLabel } = computeSpace();
|
|
226
|
+
const { spaceId, spaceLabel } = await computeSpace();
|
|
227
|
+
// Keep ephemeral published artifacts out of git (best-effort, idempotent).
|
|
228
|
+
await ensureGitignore(process.env.CLAUDE_PROJECT_DIR || process.cwd());
|
|
111
229
|
return { apiBase, token, spaceId, spaceLabel };
|
|
112
230
|
}
|
package/dist/index.js
CHANGED
|
@@ -4,7 +4,7 @@ import * as path from "node:path";
|
|
|
4
4
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
5
5
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
6
6
|
import { z } from "zod";
|
|
7
|
-
import { artifactHistory, keepArtifact, listSpace, publish, unpublishArtifact, } from "./api.js";
|
|
7
|
+
import { ApiError, artifactHistory, keepArtifact, listSpace, publish, unpublishArtifact, } from "./api.js";
|
|
8
8
|
import { loadConfig } from "./config.js";
|
|
9
9
|
import { contentTypeFromName, formatBytes, slugify, slugifyBasename, walkDir, } from "./util.js";
|
|
10
10
|
// Safety rails for a single publish_folder call (not the abuse-limits feature).
|
|
@@ -23,31 +23,16 @@ function fmtTime(ms) {
|
|
|
23
23
|
return "";
|
|
24
24
|
return new Date(ms).toISOString().slice(0, 16).replace("T", " ") + " UTC";
|
|
25
25
|
}
|
|
26
|
-
/**
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
const rel = days <= 0 ? "soon" : days === 1 ? "in ~1 day" : `in ~${days} days`;
|
|
32
|
-
return `${fmtTime(ms)} (${rel})`;
|
|
33
|
-
}
|
|
34
|
-
/** Compose the standard human-readable success message every tool returns. */
|
|
26
|
+
/**
|
|
27
|
+
* Compose the success message a publish tool returns. Deliberately terse: hand
|
|
28
|
+
* the agent the tappable link plus a one-line directive to share it plainly.
|
|
29
|
+
* No space/version/expiry chatter — that ceremony is what makes replies noisy.
|
|
30
|
+
*/
|
|
35
31
|
function publishMessage(what, res) {
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
lines.push(res.markdown);
|
|
41
|
-
lines.push("");
|
|
42
|
-
lines.push(`Live space (updates in real time): ${res.spaceUrl}`);
|
|
43
|
-
lines.push(`Direct URL: ${res.url}`);
|
|
44
|
-
if (res.expiresAt) {
|
|
45
|
-
lines.push(`Expires ${fmtExpiry(res.expiresAt)} — use \`keep\` to make it permanent.`);
|
|
46
|
-
}
|
|
47
|
-
else {
|
|
48
|
-
lines.push("This artifact has no expiry.");
|
|
49
|
-
}
|
|
50
|
-
return lines.join("\n");
|
|
32
|
+
return (`Published ${what}.\n\n` +
|
|
33
|
+
`${res.markdown}\n\n` +
|
|
34
|
+
`Share that link with the user in one short line. No emoji, no private/public ` +
|
|
35
|
+
`labels, no expiry notes — just the link.`);
|
|
51
36
|
}
|
|
52
37
|
async function main() {
|
|
53
38
|
let config;
|
|
@@ -63,18 +48,25 @@ async function main() {
|
|
|
63
48
|
process.stderr.write(`[protocontent] space ${config.spaceId}` +
|
|
64
49
|
(config.spaceLabel ? ` (${config.spaceLabel})` : "") +
|
|
65
50
|
` -> ${config.apiBase}\n`);
|
|
66
|
-
const server = new McpServer({ name: "protocontent", version: "0.
|
|
67
|
-
instructions: "Publish local
|
|
68
|
-
"
|
|
69
|
-
"
|
|
70
|
-
"
|
|
51
|
+
const server = new McpServer({ name: "protocontent", version: "0.3.0" }, {
|
|
52
|
+
instructions: "Publish a local file or folder to protocontent and share its live link. Everything you " +
|
|
53
|
+
"publish lands in this thread's persistent 'space' and updates in place when you republish " +
|
|
54
|
+
"under the same name.\n\n" +
|
|
55
|
+
"WHEN TO PUBLISH: any self-contained, browser-openable artifact — HTML reports, plans, " +
|
|
56
|
+
"dashboards, prototypes, diagrams, screenshots, or a built static site. Never publish source " +
|
|
57
|
+
"code, secrets, or files meant to be committed.\n\n" +
|
|
58
|
+
"AFTER PUBLISHING: share the returned markdown link in ONE short line — no preamble, no " +
|
|
59
|
+
"emoji, no 'private vs public' explanation, no expiry notes. To update an artifact, publish " +
|
|
60
|
+
"again with the SAME `name` (same URL). The `list` tool returns the space index link that " +
|
|
61
|
+
"shows everything; surface it only when the user asks to see the whole collection.",
|
|
71
62
|
});
|
|
72
63
|
// --- publish_html ----------------------------------------------------------
|
|
73
64
|
server.registerTool("publish_html", {
|
|
74
65
|
title: "Publish a single file",
|
|
75
|
-
description: "Publish a single HTML (or other) file or inline content to this space and
|
|
76
|
-
"
|
|
77
|
-
"
|
|
66
|
+
description: "Publish a single HTML (or other) file or inline content to this space and share its live " +
|
|
67
|
+
"link. Provide exactly one of `path` (a file on disk) or `content` (inline text; `html`/" +
|
|
68
|
+
"`body` accepted as aliases). To update an artifact, call again with the SAME `name` — it " +
|
|
69
|
+
"republishes in place at the same URL (don't invent a new name like plan-v2).",
|
|
78
70
|
inputSchema: {
|
|
79
71
|
path: z
|
|
80
72
|
.string()
|
|
@@ -84,6 +76,18 @@ async function main() {
|
|
|
84
76
|
.string()
|
|
85
77
|
.optional()
|
|
86
78
|
.describe("Inline file content to publish (alternative to `path`)."),
|
|
79
|
+
// `html`/`body` are accepted as aliases for `content`. The tool name is
|
|
80
|
+
// `publish_html`, so agents — especially when calling before the schema
|
|
81
|
+
// loads — reach for `html`. Accepting it makes the obvious guess work
|
|
82
|
+
// instead of failing with a confusing XOR error.
|
|
83
|
+
html: z
|
|
84
|
+
.string()
|
|
85
|
+
.optional()
|
|
86
|
+
.describe("Alias for `content` (inline file content)."),
|
|
87
|
+
body: z
|
|
88
|
+
.string()
|
|
89
|
+
.optional()
|
|
90
|
+
.describe("Alias for `content` (inline file content)."),
|
|
87
91
|
name: z
|
|
88
92
|
.string()
|
|
89
93
|
.optional()
|
|
@@ -94,9 +98,15 @@ async function main() {
|
|
|
94
98
|
.describe("Optional time-to-live, e.g. '7d', '24h'. Omit for the default."),
|
|
95
99
|
},
|
|
96
100
|
}, async (args) => {
|
|
97
|
-
const { path: filePath,
|
|
98
|
-
|
|
99
|
-
|
|
101
|
+
const { path: filePath, name, ttl } = args;
|
|
102
|
+
// Coalesce the inline-content aliases into a single value.
|
|
103
|
+
const content = args.content ?? args.html ?? args.body;
|
|
104
|
+
if (filePath && content !== undefined) {
|
|
105
|
+
return errorResult("Provide exactly one of `path` or `content`, not both.");
|
|
106
|
+
}
|
|
107
|
+
if (!filePath && content === undefined) {
|
|
108
|
+
return errorResult("Provide either `content` (inline text, also accepted as `html`/`body`) " +
|
|
109
|
+
"or `path` (a file on disk).");
|
|
100
110
|
}
|
|
101
111
|
let bytes;
|
|
102
112
|
let sourceName;
|
|
@@ -151,9 +161,9 @@ async function main() {
|
|
|
151
161
|
// --- publish_folder --------------------------------------------------------
|
|
152
162
|
server.registerTool("publish_folder", {
|
|
153
163
|
title: "Publish a folder",
|
|
154
|
-
description: "Recursively publish a local directory (e.g. a built static site) to this space and " +
|
|
155
|
-
"
|
|
156
|
-
"
|
|
164
|
+
description: "Recursively publish a local directory (e.g. a built static site) to this space and share " +
|
|
165
|
+
"its live link. To update it, call again with the SAME `name` — it republishes in place at " +
|
|
166
|
+
"the same URL. Skips node_modules, .git, dist, and dotfiles.",
|
|
157
167
|
inputSchema: {
|
|
158
168
|
dir: z.string().describe("Path to the directory to publish."),
|
|
159
169
|
entry: z
|
|
@@ -234,32 +244,33 @@ async function main() {
|
|
|
234
244
|
// --- list ------------------------------------------------------------------
|
|
235
245
|
server.registerTool("list", {
|
|
236
246
|
title: "List artifacts in this space",
|
|
237
|
-
description: "List
|
|
247
|
+
description: "List the artifacts in this space with their live links, plus the space index link that " +
|
|
248
|
+
"shows everything. Use it to recover an artifact's link, or to give the user the index page " +
|
|
249
|
+
"when they ask to see the whole collection.",
|
|
238
250
|
inputSchema: {},
|
|
239
251
|
}, async () => {
|
|
240
252
|
try {
|
|
241
253
|
const res = await listSpace(config);
|
|
242
|
-
const spaceUrl = `https://${config.spaceId}.protocontent.
|
|
254
|
+
const spaceUrl = res.spaceUrl || `https://${config.spaceId}.protocontent.app`;
|
|
243
255
|
if (!res.artifacts || res.artifacts.length === 0) {
|
|
244
|
-
return textResult(
|
|
245
|
-
`Space: ${spaceUrl}`);
|
|
256
|
+
return textResult("Nothing published in this space yet.");
|
|
246
257
|
}
|
|
247
|
-
const lines = res.artifacts.map((a) => {
|
|
248
|
-
|
|
249
|
-
return `- [${a.name} ↗](${a.url}) (v${a.version})${exp}`;
|
|
250
|
-
});
|
|
251
|
-
return textResult(`Artifacts in space ${config.spaceId}:\n` +
|
|
252
|
-
lines.join("\n") +
|
|
253
|
-
`\n\nLive space (updates in real time): ${spaceUrl}`);
|
|
258
|
+
const lines = res.artifacts.map((a) => `- [${a.name} ↗](${a.url})`);
|
|
259
|
+
return textResult(lines.join("\n") + `\n\nSpace index (shows everything): ${spaceUrl}`);
|
|
254
260
|
}
|
|
255
261
|
catch (err) {
|
|
262
|
+
// A space that has never been published to 404s — that's "empty", not an error.
|
|
263
|
+
if (err instanceof ApiError && err.status === 404) {
|
|
264
|
+
return textResult("Nothing published in this space yet.");
|
|
265
|
+
}
|
|
256
266
|
return errorResult(`List failed: ${err.message}`);
|
|
257
267
|
}
|
|
258
268
|
});
|
|
259
269
|
// --- history ---------------------------------------------------------------
|
|
260
270
|
server.registerTool("history", {
|
|
261
271
|
title: "Show an artifact's version history",
|
|
262
|
-
description: "Show the published version history of a named artifact in this space
|
|
272
|
+
description: "Show the published version history of a named artifact in this space (each republish under " +
|
|
273
|
+
"the same name is a new version).",
|
|
263
274
|
inputSchema: {
|
|
264
275
|
name: z.string().describe("Artifact name (slug) to show history for."),
|
|
265
276
|
},
|
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
|
+
}
|