rewritable 0.3.0 → 0.6.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 +261 -5
- package/bin/rwa.mjs +1000 -9
- package/package.json +2 -2
- package/seeds/rewritable.html +4356 -315
- package/src/agent-loop.mjs +155 -0
- package/src/apply-edits.mjs +664 -0
- package/src/atomic-write.mjs +38 -0
- package/src/backend.mjs +43 -0
- package/src/clone-extract.mjs +249 -0
- package/src/clone.mjs +161 -0
- package/src/commands.mjs +90 -10
- package/src/create.mjs +256 -0
- package/src/doc.mjs +69 -0
- package/src/dsl-compiler.mjs +357 -0
- package/src/edit.mjs +300 -0
- package/src/fetch-page.mjs +346 -0
- package/src/host.mjs +126 -0
- package/src/identity.mjs +257 -0
- package/src/import-claude.mjs +28 -4
- package/src/import-vision.mjs +1 -1
- package/src/import.mjs +76 -10
- package/src/ls.mjs +105 -0
- package/src/publish-site.mjs +85 -0
- package/src/publish.mjs +98 -0
- package/src/seed-extract.mjs +40 -0
- package/src/seed.mjs +1387 -5
- package/src/self-contained.mjs +115 -0
- package/src/skill-manifest.mjs +227 -0
- package/src/skin.mjs +350 -0
- package/src/skins.mjs +274 -0
- package/src/template.mjs +109 -0
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
// `rwa publish-site <file>` — copy a self-contained rewritable VERBATIM onto a
|
|
2
|
+
// static site over scp, and print the live URL. The durable counterpart to
|
|
3
|
+
// `rwa publish` (an ephemeral 24h service share). Because a rewritable is already
|
|
4
|
+
// one self-contained .html, we publish the bytes unchanged — no hosted projection.
|
|
5
|
+
//
|
|
6
|
+
// Design: docs/plans/2026-06-06-ikangai-custom-publish-design.md.
|
|
7
|
+
//
|
|
8
|
+
// Online by design (the offline-first invariant of new/import does not apply to
|
|
9
|
+
// a publish action). Failure surface mirrors publish.mjs: local file problems
|
|
10
|
+
// reuse the CliError `file_error` codes (exit 2); missing config / bad name are
|
|
11
|
+
// usage-class (exit 1); every transport failure is exit 4 (the bin labels exit 4
|
|
12
|
+
// `publish_error`). The transport is injected ({execFile}) so tests run offline.
|
|
13
|
+
|
|
14
|
+
import { readFile } from 'node:fs/promises';
|
|
15
|
+
import { basename, resolve } from 'node:path';
|
|
16
|
+
import { execFile as _execFile } from 'node:child_process';
|
|
17
|
+
import { promisify } from 'node:util';
|
|
18
|
+
import { extractInlineDoc } from './seed.mjs';
|
|
19
|
+
import { CliError } from './edit.mjs';
|
|
20
|
+
|
|
21
|
+
// A publishable remote name: a plain filename ending in .html. basename() already
|
|
22
|
+
// strips any directory, so this only has to reject names that survive basename and
|
|
23
|
+
// could still inject shell tokens or be otherwise unsafe. No leading dot, no
|
|
24
|
+
// path/space/metacharacters. (basename of '../../x.html' is 'x.html' — safe.)
|
|
25
|
+
const SAFE_NAME = /^[A-Za-z0-9][A-Za-z0-9._-]*\.html$/;
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* @param {string} filePath
|
|
29
|
+
* @param {{host?:string, path?:string, url?:string}} [opts] flag overrides
|
|
30
|
+
* @param {{execFile?:Function, env?:object}} [deps] injection seam for tests
|
|
31
|
+
* @returns {Promise<{name:string, url:string, remoteSpec:string}>}
|
|
32
|
+
* @throws {CliError} 2 file_error · 1 config_error/invalid_name · 4 transport
|
|
33
|
+
*/
|
|
34
|
+
export async function publishSite(filePath, opts = {}, deps = {}) {
|
|
35
|
+
const env = deps.env || process.env;
|
|
36
|
+
const execFile = deps.execFile || promisify(_execFile);
|
|
37
|
+
|
|
38
|
+
// 1. Read + validate — identical CliError file_error surface to publish.mjs.
|
|
39
|
+
let bytes;
|
|
40
|
+
try {
|
|
41
|
+
bytes = await readFile(filePath, 'utf8');
|
|
42
|
+
} catch (e) {
|
|
43
|
+
if (e && e.code === 'ENOENT') throw new CliError(2, 'not_found', { path: filePath });
|
|
44
|
+
throw new CliError(2, 'read_error', { path: filePath, errno: e && e.code, message: e && e.message });
|
|
45
|
+
}
|
|
46
|
+
try {
|
|
47
|
+
extractInlineDoc(bytes);
|
|
48
|
+
} catch {
|
|
49
|
+
throw new CliError(2, 'not_a_rewritable', { path: filePath });
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// 2. Config: flags override env; nothing is baked into the package.
|
|
53
|
+
const host = opts.host || env.RWA_SITE_HOST;
|
|
54
|
+
const remotePath = opts.path || env.RWA_SITE_PATH;
|
|
55
|
+
const urlBase = opts.url || env.RWA_SITE_URL;
|
|
56
|
+
const missing = [];
|
|
57
|
+
if (!host) missing.push('RWA_SITE_HOST');
|
|
58
|
+
if (!remotePath) missing.push('RWA_SITE_PATH');
|
|
59
|
+
if (!urlBase) missing.push('RWA_SITE_URL');
|
|
60
|
+
if (missing.length) throw new CliError(1, 'config_error', { missing });
|
|
61
|
+
|
|
62
|
+
// 3. Remote name: basename only, then allowlist. Stops path traversal AND
|
|
63
|
+
// shell-token injection at the same gate.
|
|
64
|
+
const name = basename(filePath);
|
|
65
|
+
if (!SAFE_NAME.test(name)) throw new CliError(1, 'invalid_name', { name });
|
|
66
|
+
|
|
67
|
+
// 4. Transport. execFile with an ARGUMENT ARRAY (never a shell string), and
|
|
68
|
+
// `--` so a leading-dash path is not parsed as an scp option. The local
|
|
69
|
+
// source is an ABSOLUTE path so scp never mis-reads an embedded ':' as a
|
|
70
|
+
// remote host. scp overwrites the destination → republish is idempotent.
|
|
71
|
+
const remoteDir = remotePath.replace(/\/+$/, '');
|
|
72
|
+
const remoteSpec = `${host}:${remoteDir}/${name}`;
|
|
73
|
+
try {
|
|
74
|
+
await execFile('scp', ['--', resolve(filePath), remoteSpec]);
|
|
75
|
+
} catch (e) {
|
|
76
|
+
if (e && e.code === 'ENOENT') throw new CliError(4, 'scp_not_found', {});
|
|
77
|
+
throw new CliError(4, 'transport_error', {
|
|
78
|
+
stderr: (e && e.stderr) || '', code: e && e.code, message: e && e.message,
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// 5. Result.
|
|
83
|
+
const url = `${urlBase.replace(/\/+$/, '')}/${name}`;
|
|
84
|
+
return { name, url, remoteSpec };
|
|
85
|
+
}
|
package/src/publish.mjs
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
// `rwa publish <file>` — publish a local rewritable to the service's snapshot
|
|
2
|
+
// endpoint (`POST /publish`, service/server.js) and return the share URL.
|
|
3
|
+
//
|
|
4
|
+
// This is a THIN, honest client for an endpoint that already exists. The only
|
|
5
|
+
// first-class path for "share MY locally-edited file": create with `rwa new`,
|
|
6
|
+
// edit locally, `rwa publish`. The browser UIs (new.html / import.html) publish
|
|
7
|
+
// a fresh or newly-converted container — never the user's edited bytes.
|
|
8
|
+
//
|
|
9
|
+
// Unlike `rwa new`/`rwa import`, this command is intentionally ONLINE; the
|
|
10
|
+
// offline-first invariant does not apply to a publish action.
|
|
11
|
+
//
|
|
12
|
+
// Failure surface mirrors `rwa doc`/`rwa edit`: local file problems reuse the
|
|
13
|
+
// CliError `file_error` codes (exit 2); every remote/network failure is exit 4
|
|
14
|
+
// with an honest subcode (the bin labels exit 4 `publish_error`, not the shared
|
|
15
|
+
// `agent_error`). The server's own `validateContainer` stays the single source
|
|
16
|
+
// of validation truth — the local check here is only fail-fast.
|
|
17
|
+
|
|
18
|
+
import { readFile } from 'node:fs/promises';
|
|
19
|
+
import { extractInlineDoc } from './seed.mjs';
|
|
20
|
+
import { CliError } from './edit.mjs';
|
|
21
|
+
|
|
22
|
+
// Hardcoded production default. Overridable via --url (bin) or RWA_PUBLISH_URL.
|
|
23
|
+
export const DEFAULT_PUBLISH_URL = 'https://rewritable.ikangai.com';
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Read, locally validate, and POST a rewritable's bytes to `<baseUrl>/publish`.
|
|
27
|
+
*
|
|
28
|
+
* @param {string} filePath
|
|
29
|
+
* @param {{ baseUrl?: string }} [opts] - baseUrl is the service ORIGIN; the
|
|
30
|
+
* `/publish` path is appended here. Falls back to DEFAULT_PUBLISH_URL.
|
|
31
|
+
* @returns {Promise<{short:string, url:string, expiresAt:number}>} the server's
|
|
32
|
+
* success object on 201.
|
|
33
|
+
* @throws {CliError} exit 2 (file_error: not_found/read_error/not_a_rewritable)
|
|
34
|
+
* before any network call; exit 4 on every remote/network failure.
|
|
35
|
+
*/
|
|
36
|
+
export async function publishCmd(filePath, { baseUrl } = {}) {
|
|
37
|
+
// 1. Read — identical CliError file_error surface to doc.mjs.
|
|
38
|
+
let bytes;
|
|
39
|
+
try {
|
|
40
|
+
bytes = await readFile(filePath, 'utf8');
|
|
41
|
+
} catch (e) {
|
|
42
|
+
if (e && e.code === 'ENOENT') throw new CliError(2, 'not_found', { path: filePath });
|
|
43
|
+
throw new CliError(2, 'read_error', { path: filePath, errno: e && e.code, message: e && e.message });
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// 2. Local fail-fast: is this even a rewritable? Same gate as `rwa doc`.
|
|
47
|
+
// The server re-validates authoritatively; this just avoids a wasted round
|
|
48
|
+
// trip and gives an offline-detectable error.
|
|
49
|
+
try {
|
|
50
|
+
extractInlineDoc(bytes);
|
|
51
|
+
} catch {
|
|
52
|
+
throw new CliError(2, 'not_a_rewritable', { path: filePath });
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// 3. POST the raw bytes. The server reads the body raw and ignores
|
|
56
|
+
// content-type; text/html is the honest label for the payload.
|
|
57
|
+
const base = (baseUrl || DEFAULT_PUBLISH_URL).replace(/\/+$/, '');
|
|
58
|
+
const endpoint = `${base}/publish`;
|
|
59
|
+
let res;
|
|
60
|
+
try {
|
|
61
|
+
res = await fetch(endpoint, {
|
|
62
|
+
method: 'POST',
|
|
63
|
+
headers: { 'Content-Type': 'text/html; charset=utf-8' },
|
|
64
|
+
body: bytes,
|
|
65
|
+
});
|
|
66
|
+
} catch (e) {
|
|
67
|
+
throw new CliError(4, 'network_error', { url: endpoint, message: (e && e.message) || String(e) });
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Body may be empty or non-JSON on some error paths — parse defensively.
|
|
71
|
+
const text = await res.text();
|
|
72
|
+
let payload = null;
|
|
73
|
+
if (text) { try { payload = JSON.parse(text); } catch { payload = null; } }
|
|
74
|
+
|
|
75
|
+
if (res.status === 201) {
|
|
76
|
+
if (!payload || typeof payload.url !== 'string') {
|
|
77
|
+
throw new CliError(4, 'server_error', { status: 201, error: 'malformed_success_response' });
|
|
78
|
+
}
|
|
79
|
+
return payload; // { short, url, expiresAt }
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Map the server's error envelope to an honest subcode. Prefer the server's
|
|
83
|
+
// own `error` name when present, fall back to the HTTP status.
|
|
84
|
+
const errName = payload && typeof payload.error === 'string' ? payload.error : null;
|
|
85
|
+
if (res.status === 413 || errName === 'body_too_large') {
|
|
86
|
+
throw new CliError(4, 'body_too_large', { maxBytes: payload && payload.maxBytes });
|
|
87
|
+
}
|
|
88
|
+
if (res.status === 429 || errName === 'rate_limited') {
|
|
89
|
+
throw new CliError(4, 'rate_limited', { retryAfterSec: payload && payload.retryAfterSec });
|
|
90
|
+
}
|
|
91
|
+
if (res.status === 400) {
|
|
92
|
+
throw new CliError(4, 'validation_failed', { detail: payload && payload.detail, error: errName });
|
|
93
|
+
}
|
|
94
|
+
if (res.status >= 500) {
|
|
95
|
+
throw new CliError(4, 'server_error', { status: res.status, error: errName });
|
|
96
|
+
}
|
|
97
|
+
throw new CliError(4, 'unexpected_status', { status: res.status, error: errName });
|
|
98
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
const EXTRACT_BEGIN = (name) => `// rwa:extract:begin ${name}`;
|
|
2
|
+
const EXTRACT_END = (name) => `// rwa:extract:end ${name}`;
|
|
3
|
+
|
|
4
|
+
function extractBlock(seedText, name) {
|
|
5
|
+
const begin = EXTRACT_BEGIN(name);
|
|
6
|
+
const end = EXTRACT_END(name);
|
|
7
|
+
const startIdx = seedText.indexOf(begin);
|
|
8
|
+
if (startIdx === -1) throw new Error(`seed-extract: missing begin marker for ${name}`);
|
|
9
|
+
const endIdx = seedText.indexOf(end, startIdx);
|
|
10
|
+
if (endIdx === -1) throw new Error(`seed-extract: missing end marker for ${name}`);
|
|
11
|
+
return seedText.slice(startIdx + begin.length, endIdx);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function evalConstBlock(block, name, deps = {}) {
|
|
15
|
+
// Block contains: `\nconst NAME = { ... };\n`
|
|
16
|
+
// Evaluate in an isolated function scope. `deps` lets us inject upstream
|
|
17
|
+
// consts (e.g. SYSTEM_PROMPTS references SYSTEM_PROMPT_RULES via template
|
|
18
|
+
// interpolation). Block must otherwise reference only built-ins.
|
|
19
|
+
const depNames = Object.keys(deps);
|
|
20
|
+
const depValues = depNames.map(k => deps[k]);
|
|
21
|
+
const fn = new Function(...depNames, `${block}\nreturn ${name};`);
|
|
22
|
+
return fn(...depValues);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function extractFromSeed(seedText) {
|
|
26
|
+
const SYSTEM_PROMPT_RULES = evalConstBlock(
|
|
27
|
+
extractBlock(seedText, 'SYSTEM_PROMPT_RULES'),
|
|
28
|
+
'SYSTEM_PROMPT_RULES'
|
|
29
|
+
);
|
|
30
|
+
const SYSTEM_PROMPTS = evalConstBlock(
|
|
31
|
+
extractBlock(seedText, 'SYSTEM_PROMPTS'),
|
|
32
|
+
'SYSTEM_PROMPTS',
|
|
33
|
+
{ SYSTEM_PROMPT_RULES }
|
|
34
|
+
);
|
|
35
|
+
const TOOL_SCHEMAS = evalConstBlock(
|
|
36
|
+
extractBlock(seedText, 'TOOL_SCHEMAS'),
|
|
37
|
+
'TOOL_SCHEMAS'
|
|
38
|
+
);
|
|
39
|
+
return { SYSTEM_PROMPTS, SYSTEM_PROMPT_RULES, TOOL_SCHEMAS };
|
|
40
|
+
}
|