mnfst-publish 0.1.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 ADDED
@@ -0,0 +1,19 @@
1
+ # mnfst-publish
2
+
3
+ One command to put a Manifest project online (managed hosting):
4
+
5
+ ```bash
6
+ npx mnfst-publish # build + publish to production, print the live URL
7
+ npx mnfst-publish --staging # publish to a staging preview URL
8
+ npx mnfst-publish --promote # promote the current staging build to production
9
+ ```
10
+
11
+ It reads your project's API key from `.env` (`MANIFEST_API_KEY`) and the MCP
12
+ endpoint from `.mcp.json`, renders the site if it's a prerendered ("website")
13
+ project, zips it gitignore-aware, uploads it, and prints the live URL.
14
+
15
+ Flags: `--staging` / `--production` (default), `--no-render`, `--promote`,
16
+ `--source render|spa` (auto-detected), `--key <key>`, `--mcp <url>`.
17
+
18
+ Zero dependencies, cross-platform (pure-Node zip). `.env`, `.env.*`, and
19
+ `.claude/` are never uploaded.
@@ -0,0 +1,7 @@
1
+ #!/usr/bin/env node
2
+ import { main } from '../manifest.publish.mjs';
3
+
4
+ main().catch((err) => {
5
+ console.error('mnfst-publish:', err && err.message ? err.message : err);
6
+ process.exit(1);
7
+ });
@@ -0,0 +1,295 @@
1
+ // mnfst-publish — one-command managed publishing for Manifest projects.
2
+ //
3
+ // Replaces the old "tool hands you a zip|curl with a scraped token" flow (which
4
+ // reads as data-exfiltration to safety tooling and is jargon-heavy for users).
5
+ // This is a single named command: it reads the project's API key from .env, does
6
+ // the MCP publish handshake, renders if needed, zips gitignore-aware, uploads,
7
+ // and prints the live URL. Zero npm deps; cross-platform (pure-Node zip).
8
+
9
+ import { spawnSync } from 'node:child_process';
10
+ import { createHash } from 'node:crypto';
11
+ import { deflateRawSync } from 'node:zlib';
12
+ import { readFileSync, existsSync, statSync, readdirSync } from 'node:fs';
13
+ import { join, dirname, relative, sep } from 'node:path';
14
+
15
+ const DEFAULT_MCP = 'https://manifest-mcp.manifest-c5f.workers.dev/mcp';
16
+
17
+ function log(msg) {
18
+ process.stdout.write(msg + '\n');
19
+ }
20
+ function fail(msg) {
21
+ console.error('mnfst-publish: ' + msg);
22
+ process.exit(1);
23
+ }
24
+
25
+ function parseArgs(argv) {
26
+ const out = { env: 'production', render: undefined, promote: false };
27
+ for (let i = 0; i < argv.length; i++) {
28
+ const a = argv[i];
29
+ if (a === '--staging') out.env = 'staging';
30
+ else if (a === '--production' || a === '--prod') out.env = 'production';
31
+ else if (a === '--env') out.env = argv[++i];
32
+ else if (a === '--source') out.source = argv[++i];
33
+ else if (a === '--no-render') out.render = false;
34
+ else if (a === '--render') out.render = true;
35
+ else if (a === '--promote') out.promote = true;
36
+ else if (a === '--key') out.key = argv[++i];
37
+ else if (a === '--mcp') out.mcp = argv[++i];
38
+ else if (a === '--root') out.root = argv[++i];
39
+ else if (a === '-h' || a === '--help') out.help = true;
40
+ }
41
+ if (out.env !== 'staging' && out.env !== 'production') fail(`--env must be "staging" or "production" (got "${out.env}")`);
42
+ return out;
43
+ }
44
+
45
+ // Walk up from cwd to find the project root (where manifest.json or .mcp.json lives).
46
+ function findRoot(start) {
47
+ let dir = start;
48
+ for (let i = 0; i < 8; i++) {
49
+ if (existsSync(join(dir, 'manifest.json')) || existsSync(join(dir, '.mcp.json'))) return dir;
50
+ const parent = dirname(dir);
51
+ if (parent === dir) break;
52
+ dir = parent;
53
+ }
54
+ return start;
55
+ }
56
+
57
+ function readApiKey(root, explicit) {
58
+ if (explicit) return explicit;
59
+ if (process.env.MANIFEST_API_KEY) return process.env.MANIFEST_API_KEY;
60
+ for (const name of ['.env.manifest', '.env']) {
61
+ const p = join(root, name);
62
+ if (!existsSync(p)) continue;
63
+ const m = readFileSync(p, 'utf8').match(/^\s*MANIFEST_API_KEY\s*=\s*["']?([^"'\r\n]+)/m);
64
+ if (m) return m[1].trim();
65
+ }
66
+ return null;
67
+ }
68
+
69
+ function readMcpUrl(root, explicit) {
70
+ if (explicit) return explicit;
71
+ const p = join(root, '.mcp.json');
72
+ if (existsSync(p)) {
73
+ try {
74
+ const cfg = JSON.parse(readFileSync(p, 'utf8'));
75
+ const server = cfg.mcpServers && (cfg.mcpServers.manifest || Object.values(cfg.mcpServers)[0]);
76
+ if (server && server.url) return server.url;
77
+ } catch {
78
+ /* fall through to default */
79
+ }
80
+ }
81
+ return DEFAULT_MCP;
82
+ }
83
+
84
+ // "render" when the project prerenders (manifest.json has a prerender block or a
85
+ // /website output dir exists); otherwise a root-served SPA.
86
+ function detectSource(root, explicit) {
87
+ if (explicit) return explicit;
88
+ try {
89
+ const mf = JSON.parse(readFileSync(join(root, 'manifest.json'), 'utf8'));
90
+ if (mf && (mf.prerender || mf.render)) return 'render';
91
+ } catch {
92
+ /* ignore */
93
+ }
94
+ return existsSync(join(root, 'website')) ? 'render' : 'spa';
95
+ }
96
+
97
+ // --- MCP JSON-RPC over Streamable HTTP -------------------------------------
98
+
99
+ async function mcp(url, key, method, params, sessionId) {
100
+ const headers = {
101
+ 'content-type': 'application/json',
102
+ accept: 'application/json, text/event-stream',
103
+ 'x-api-key': key,
104
+ };
105
+ if (sessionId) headers['mcp-session-id'] = sessionId;
106
+ const res = await fetch(url, { method: 'POST', headers, body: JSON.stringify({ jsonrpc: '2.0', id: 1, method, params }) });
107
+ const sid = res.headers.get('mcp-session-id') || sessionId;
108
+ const text = await res.text();
109
+ // Response may be a JSON object or an SSE "data: {...}" line.
110
+ const line = text.split('\n').find((l) => l.startsWith('data:'));
111
+ const body = line ? line.slice(5).trim() : text;
112
+ let json = null;
113
+ try {
114
+ json = body ? JSON.parse(body) : null;
115
+ } catch {
116
+ /* leave null */
117
+ }
118
+ return { json, sessionId: sid, status: res.status, raw: text };
119
+ }
120
+
121
+ async function callTool(url, key, name, args) {
122
+ const init = await mcp(url, key, 'initialize', {
123
+ protocolVersion: '2024-11-05',
124
+ capabilities: {},
125
+ clientInfo: { name: 'mnfst-publish', version: '0.1.0' },
126
+ });
127
+ const sid = init.sessionId;
128
+ if (!sid) throw new Error('no MCP session — check your API key and network connection');
129
+ await mcp(url, key, 'notifications/initialized', {}, sid);
130
+ const res = await mcp(url, key, 'tools/call', { name, arguments: args }, sid);
131
+ const result = res.json && res.json.result;
132
+ if (!result) throw new Error(`unexpected response from ${name} (HTTP ${res.status})`);
133
+ const textPart = (result.content || []).find((c) => c.type === 'text');
134
+ const payloadText = textPart ? textPart.text : '';
135
+ if (result.isError) throw new Error(payloadText || `${name} failed`);
136
+ try {
137
+ return JSON.parse(payloadText);
138
+ } catch {
139
+ return { _text: payloadText };
140
+ }
141
+ }
142
+
143
+ // --- File collection (gitignore-aware) -------------------------------------
144
+
145
+ function collectFiles(root) {
146
+ const git = spawnSync('git', ['ls-files', '-co', '--exclude-standard'], { cwd: root, encoding: 'utf8' });
147
+ let rels;
148
+ if (git.status === 0) {
149
+ rels = git.stdout.split('\n').map((s) => s.trim()).filter(Boolean);
150
+ } else {
151
+ // Not a git repo — walk, skipping the usual heavy/secret dirs.
152
+ const skip = new Set(['.git', 'node_modules', '.claude']);
153
+ rels = [];
154
+ const walk = (dir) => {
155
+ for (const name of readdirSync(dir)) {
156
+ if (skip.has(name)) continue;
157
+ const abs = join(dir, name);
158
+ const st = statSync(abs);
159
+ if (st.isDirectory()) walk(abs);
160
+ else rels.push(relative(root, abs).split(sep).join('/'));
161
+ }
162
+ };
163
+ walk(root);
164
+ }
165
+ // Never ship local config or secrets.
166
+ return rels.filter((r) => !r.startsWith('.claude/') && r !== '.env' && !r.startsWith('.env.') && r !== '.env');
167
+ }
168
+
169
+ // --- Minimal ZIP writer (DEFLATE), pure Node, no deps ----------------------
170
+
171
+ function crc32(buf) {
172
+ let c = ~0;
173
+ for (let i = 0; i < buf.length; i++) {
174
+ c ^= buf[i];
175
+ for (let k = 0; k < 8; k++) c = (c >>> 1) ^ (0xedb88320 & -(c & 1));
176
+ }
177
+ return (~c) >>> 0;
178
+ }
179
+
180
+ function buildZip(root, rels) {
181
+ const chunks = [];
182
+ const central = [];
183
+ let offset = 0;
184
+ for (const rel of rels) {
185
+ const data = readFileSync(join(root, rel));
186
+ const nameBuf = Buffer.from(rel, 'utf8');
187
+ const crc = crc32(data);
188
+ const deflated = deflateRawSync(data);
189
+ // Store uncompressed if deflate didn't help (e.g. already-compressed assets).
190
+ const useStore = deflated.length >= data.length;
191
+ const method = useStore ? 0 : 8;
192
+ const body = useStore ? data : deflated;
193
+
194
+ const local = Buffer.alloc(30);
195
+ local.writeUInt32LE(0x04034b50, 0);
196
+ local.writeUInt16LE(20, 4); // version needed
197
+ local.writeUInt16LE(0, 6); // flags
198
+ local.writeUInt16LE(method, 8);
199
+ local.writeUInt16LE(0, 10); // mod time
200
+ local.writeUInt16LE(0x21, 12); // mod date (1980-01-01)
201
+ local.writeUInt32LE(crc, 14);
202
+ local.writeUInt32LE(body.length, 18);
203
+ local.writeUInt32LE(data.length, 22);
204
+ local.writeUInt16LE(nameBuf.length, 26);
205
+ local.writeUInt16LE(0, 28);
206
+ chunks.push(local, nameBuf, body);
207
+
208
+ const cen = Buffer.alloc(46);
209
+ cen.writeUInt32LE(0x02014b50, 0);
210
+ cen.writeUInt16LE(20, 4); // version made by
211
+ cen.writeUInt16LE(20, 6); // version needed
212
+ cen.writeUInt16LE(0, 8); // flags
213
+ cen.writeUInt16LE(method, 10);
214
+ cen.writeUInt16LE(0, 12);
215
+ cen.writeUInt16LE(0x21, 14);
216
+ cen.writeUInt32LE(crc, 16);
217
+ cen.writeUInt32LE(body.length, 20);
218
+ cen.writeUInt32LE(data.length, 24);
219
+ cen.writeUInt16LE(nameBuf.length, 28);
220
+ cen.writeUInt32LE(offset, 42);
221
+ central.push(Buffer.concat([cen, nameBuf]));
222
+
223
+ offset += local.length + nameBuf.length + body.length;
224
+ }
225
+ const centralBuf = Buffer.concat(central);
226
+ const end = Buffer.alloc(22);
227
+ end.writeUInt32LE(0x06054b50, 0);
228
+ end.writeUInt16LE(rels.length, 8);
229
+ end.writeUInt16LE(rels.length, 10);
230
+ end.writeUInt32LE(centralBuf.length, 12);
231
+ end.writeUInt32LE(offset, 16);
232
+ return Buffer.concat([...chunks, centralBuf, end]);
233
+ }
234
+
235
+ // --- Main ------------------------------------------------------------------
236
+
237
+ export async function main() {
238
+ const opts = parseArgs(process.argv.slice(2));
239
+ if (opts.help) {
240
+ log('Usage: npx mnfst-publish [--staging|--production] [--no-render] [--promote]');
241
+ log('Publishes the current Manifest project to managed hosting and prints the live URL.');
242
+ return;
243
+ }
244
+
245
+ const root = opts.root ? opts.root : findRoot(process.cwd());
246
+ const key = readApiKey(root, opts.key);
247
+ if (!key) fail('no API key found. Expected MANIFEST_API_KEY in .env (this folder doesn’t look like a Manifest project, or it isn’t set up for publishing).');
248
+ const url = readMcpUrl(root, opts.mcp);
249
+ const source = detectSource(root, opts.source);
250
+
251
+ // Promote a previously-staged build straight to production (no upload).
252
+ if (opts.promote) {
253
+ log('Promoting the staged version to production…');
254
+ const res = await callTool(url, key, 'manifest_promote', {});
255
+ log('✓ Live: ' + (res.url || res._text || 'production updated'));
256
+ return;
257
+ }
258
+
259
+ if (source === 'render' && opts.render !== false) {
260
+ log('Rendering the site…');
261
+ const r = spawnSync('npx', ['mnfst-render'], { cwd: root, stdio: 'inherit', shell: process.platform === 'win32' });
262
+ if (r.status !== 0) fail('render failed — fix the errors above and try again.');
263
+ }
264
+
265
+ log(`Preparing ${opts.env} deploy…`);
266
+ const handshake = await callTool(url, key, 'manifest_publish', { env: opts.env, source, via_cli: true });
267
+ if (handshake.already_pro) { /* not applicable */ }
268
+ const uploadUrl = handshake.upload_url;
269
+ if (!uploadUrl) fail(handshake._text || 'could not start the publish (no upload URL returned).');
270
+
271
+ const rels = collectFiles(root);
272
+ if (!rels.length) fail('nothing to publish (no files found).');
273
+ const zip = buildZip(root, rels);
274
+ log(`Uploading ${rels.length} files (${(zip.length / 1048576).toFixed(1)} MB)…`);
275
+
276
+ const up = await fetch(uploadUrl, {
277
+ method: 'POST',
278
+ headers: { 'content-type': 'application/zip' },
279
+ body: zip,
280
+ });
281
+ const upText = await up.text();
282
+ let upJson = null;
283
+ try {
284
+ upJson = JSON.parse(upText);
285
+ } catch {
286
+ /* ignore */
287
+ }
288
+ if (!up.ok || !upJson || upJson.ok !== true) {
289
+ fail(`upload failed (HTTP ${up.status}): ${upText.slice(0, 300)}`);
290
+ }
291
+
292
+ log('');
293
+ log(`✓ Published to ${opts.env}: ${upJson.url}`);
294
+ if (opts.env === 'staging') log(' Review it, then run `npx mnfst-publish --promote` to take it live.');
295
+ }
package/package.json ADDED
@@ -0,0 +1,36 @@
1
+ {
2
+ "name": "mnfst-publish",
3
+ "version": "0.1.0",
4
+ "description": "One-command managed publishing for Manifest projects — render, zip, upload, live URL.",
5
+ "type": "module",
6
+ "bin": {
7
+ "mnfst-publish": "./bin/mnfst-publish.js"
8
+ },
9
+ "files": [
10
+ "bin",
11
+ "manifest.publish.mjs",
12
+ "README.md"
13
+ ],
14
+ "publishConfig": {
15
+ "access": "public"
16
+ },
17
+ "keywords": [
18
+ "manifest",
19
+ "mnfst",
20
+ "publish",
21
+ "deploy",
22
+ "hosting",
23
+ "static",
24
+ "ci"
25
+ ],
26
+ "engines": {
27
+ "node": ">=18"
28
+ },
29
+ "author": "Andrew Matlock",
30
+ "license": "MIT",
31
+ "repository": {
32
+ "type": "git",
33
+ "url": "git+https://github.com/andrewmatlock/Manifest.git",
34
+ "directory": "packages/publish"
35
+ }
36
+ }