@blocofy/cli 0.16.0 → 0.17.1
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 +24 -1
- package/bin/blocofy.mjs +29 -5
- package/lib/dev-server.mjs +61 -6
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -132,6 +132,9 @@ and editor views for this command are retired; to share a draft page, create a p
|
|
|
132
132
|
it (v1 API `POST /themes/{id}/preview-links` or MCP `create_preview_link`; one page per link).
|
|
133
133
|
The target site is verified once at start; a long session keeps that target (restart it after
|
|
134
134
|
changing credentials, context or the project binding).
|
|
135
|
+
The dev server answers this machine only: it listens on `http://127.0.0.1:<port>`, refuses a request
|
|
136
|
+
whose `Host` (or `Origin`, when sent) is not that address or `localhost:<port>`, and answers only
|
|
137
|
+
`GET`/`HEAD` (anything else gets `405`). The reload channel is same-origin only.
|
|
135
138
|
- `--port <n>` — local port (default 3030).
|
|
136
139
|
- `--no-sync` — local preview only (skip draft sync).
|
|
137
140
|
- `--name <name>` — name the draft when it is first created (ignored if it already exists).
|
|
@@ -623,7 +626,27 @@ Each retry prints a notice on stderr.
|
|
|
623
626
|
|
|
624
627
|
## Changelog
|
|
625
628
|
|
|
626
|
-
- **0.
|
|
629
|
+
- **0.17.1** (2026-11-01) — Security fix: `theme dev` answers this machine only.
|
|
630
|
+
- The local server listens on IPv4 loopback (`127.0.0.1`) instead of every network interface, and
|
|
631
|
+
prints that address. There is no option to open it to the network.
|
|
632
|
+
- A request whose `Host` is not `127.0.0.1:<port>` / `localhost:<port>`, or whose `Origin` is another
|
|
633
|
+
site, is refused before anything is read or rendered (DNS-rebinding and cross-site pages).
|
|
634
|
+
- Only `GET` and `HEAD` are answered; other methods get `405` with `Allow: GET, HEAD` and never reach
|
|
635
|
+
the platform. `HEAD` returns headers only and never spends a render.
|
|
636
|
+
- The reload channel no longer sends `Access-Control-Allow-Origin: *`; the local page still reloads on
|
|
637
|
+
save. Views on other origins no longer subscribe to it.
|
|
638
|
+
- **0.17.0** (2026-10-31) — `theme publish` tells you when the live site is not serving the files
|
|
639
|
+
you published.
|
|
640
|
+
- **`theme publish` reports an unrefreshed live site.** When the platform answers
|
|
641
|
+
`live_sealed: false`, the CLI prints the reason and the fix (`blocofy theme push --live --yes`)
|
|
642
|
+
and exits 4 (not complete) instead of reporting success. Platforms that do not send the field
|
|
643
|
+
behave as before.
|
|
644
|
+
- **`blocofy status` shows live file freshness** ("Live files: up to date", or which files are
|
|
645
|
+
stale, missing or extra on the live site).
|
|
646
|
+
- `theme publish` accepts `--yes` / `--confirm` (no prompt to skip; accepted so scripts that pass
|
|
647
|
+
it to every live command do not fail).
|
|
648
|
+
- `theme dev` no longer prints a preview URL containing `undefined&hr=` for a session without one.
|
|
649
|
+
- **0.16.0** (2026-10-06) — Browser login, `blocofy init`, honest `logout` (ADR-0014 wave P4).
|
|
627
650
|
Pairs with the platform release of the credential profiles (draft-only "Tema geliştirme" by
|
|
628
651
|
default). **Minimum platform:** the browser login needs a platform that publishes the CLI client
|
|
629
652
|
(`blocofy-cli`), RFC 9207 `iss`, `/revoke` and the `profile` / `audience` / `dev_endpoint` fields of
|
package/bin/blocofy.mjs
CHANGED
|
@@ -100,7 +100,8 @@ const KNOWN = {
|
|
|
100
100
|
themePull: ["draft", "instance"],
|
|
101
101
|
themePush: ["diff", "draft", "instance", "name", "live", "yes", "confirm", "dry-run", "validate", "idempotency-key", "prune", "work"],
|
|
102
102
|
themeDev: ["port", "dry", "no-sync", "name", "instance"],
|
|
103
|
-
|
|
103
|
+
// `yes`/`confirm` are accepted (no-op: publish never prompts) so `--yes` works the same as on `theme push --live`.
|
|
104
|
+
themePublish: ["instance", "yes", "confirm"],
|
|
104
105
|
themeRename: ["name", "dir"],
|
|
105
106
|
themeWorkStart: ["intent", "idempotency-key"],
|
|
106
107
|
themeWorkStatus: ["dir"],
|
|
@@ -2595,9 +2596,11 @@ async function themeDev(rest) {
|
|
|
2595
2596
|
}
|
|
2596
2597
|
}
|
|
2597
2598
|
|
|
2598
|
-
|
|
2599
|
-
const
|
|
2600
|
-
|
|
2599
|
+
// The URL the dev server actually binds (IPv4 loopback): `localhost` may resolve to ::1 first.
|
|
2600
|
+
const localUrl = `http://127.0.0.1:${port}`;
|
|
2601
|
+
// A session without a URL (an older platform answer) must not become "undefined&hr=…".
|
|
2602
|
+
const previewUrl = session?.previewUrl ? `${session.previewUrl}&hr=${port}` : null;
|
|
2603
|
+
const editorUrl = session?.editorUrl ? `${session.editorUrl}&hr=${port}` : null;
|
|
2601
2604
|
|
|
2602
2605
|
// Çözülen site'ı göster (session TOKEN'dan çözer) → hangi tenant'ı düzenlediğin belli olsun.
|
|
2603
2606
|
const siteLine = session?.site ? `${siteLabel(session.site)} · ` : "";
|
|
@@ -2614,7 +2617,7 @@ async function themeDev(rest) {
|
|
|
2614
2617
|
.filter(Boolean)
|
|
2615
2618
|
.join(" ");
|
|
2616
2619
|
console.log(`\n Press: ${keys}`);
|
|
2617
|
-
console.log(` Edit a theme file and save —
|
|
2620
|
+
console.log(` Edit a theme file and save — the local view reloads automatically.\n`);
|
|
2618
2621
|
|
|
2619
2622
|
// Senkron kapsamı (#119 CLI bulgu #2): hangi dizinler taşınıyor / taşınmıyor —
|
|
2620
2623
|
// "config/pages senkronlanıyor sandım" karışıklığını açıkça önler.
|
|
@@ -2778,6 +2781,16 @@ async function themePublish(rest) {
|
|
|
2778
2781
|
console.log(
|
|
2779
2782
|
`✓ Theme ${result.published} is now LIVE${result.cloned ? " (pages cloned from the previous live theme)" : ""}.`,
|
|
2780
2783
|
);
|
|
2784
|
+
// The platform seals the new live theme's current files on publish and says whether it did. Not sealed = the
|
|
2785
|
+
// live site may still serve the files this theme had at its last seal (an older app.js): exit 4 so scripts
|
|
2786
|
+
// notice, and say how to seal again. An older platform sends no `live_sealed`: nothing changes.
|
|
2787
|
+
if (result.live_sealed === false) {
|
|
2788
|
+
console.error(
|
|
2789
|
+
`Warning: the live site may still serve this theme's previous files (live files not refreshed: ${result.live_seal_reason ?? "unknown"}).\n` +
|
|
2790
|
+
`Refresh them with: blocofy theme push --live --yes`,
|
|
2791
|
+
);
|
|
2792
|
+
process.exitCode = 4;
|
|
2793
|
+
}
|
|
2781
2794
|
}
|
|
2782
2795
|
|
|
2783
2796
|
// ── theme work ("çalışma"): a safe, private copy of the live theme, continued by its exact handle ──────────────
|
|
@@ -3242,6 +3255,17 @@ async function status(rest) {
|
|
|
3242
3255
|
: `Live theme: none`,
|
|
3243
3256
|
);
|
|
3244
3257
|
console.log(`Health: ${s.health}`);
|
|
3258
|
+
// What live actually serves vs. the stored theme files (platforms that report it).
|
|
3259
|
+
const ls = s.live_source;
|
|
3260
|
+
if (ls?.pinned === true && ls.in_sync === false) {
|
|
3261
|
+
const paths = [...(ls.stale ?? []), ...(ls.missing ?? []), ...(ls.extra ?? [])];
|
|
3262
|
+
console.error(`Live files: OUT OF DATE — the live site does not serve ${paths.length} stored file(s): ${paths.slice(0, 5).join(", ")}${paths.length > 5 ? ", …" : ""}`);
|
|
3263
|
+
console.error(" Refresh them with: blocofy theme push --live --yes");
|
|
3264
|
+
} else if (ls?.pinned === true) {
|
|
3265
|
+
console.log("Live files: up to date");
|
|
3266
|
+
} else if (ls?.pinned === null) {
|
|
3267
|
+
console.error("Live files: could not be checked right now");
|
|
3268
|
+
}
|
|
3245
3269
|
// CF-T9: no imperative one-line fix — which theme holds the pages, why, and safe (preview-first) next steps.
|
|
3246
3270
|
for (const line of healthAdvice(s)) console.error(line);
|
|
3247
3271
|
if (Array.isArray(s.drafts) && s.drafts.length > 0) {
|
package/lib/dev-server.mjs
CHANGED
|
@@ -11,6 +11,43 @@ import {
|
|
|
11
11
|
} from "./local-theme.mjs";
|
|
12
12
|
import { DRAFT_TARGET_AMBIGUOUS, draftSyncErrorLine, pushTheme } from "./theme-sync.mjs";
|
|
13
13
|
|
|
14
|
+
/**
|
|
15
|
+
* The dev server proxies page renders with the developer's bearer, so it answers only this machine:
|
|
16
|
+
* it binds IPv4 loopback, and every request must name it (Host + optional Origin) before any branch runs.
|
|
17
|
+
* There is no LAN option on purpose — exposing it needs its own auth/threat model.
|
|
18
|
+
*/
|
|
19
|
+
const DEV_SERVER_HOST = "127.0.0.1";
|
|
20
|
+
const ALLOWED_METHODS = "GET, HEAD";
|
|
21
|
+
|
|
22
|
+
/** Why `req` is refused (status + short reason; never echoes request data or the credential), or null. */
|
|
23
|
+
function refusal(req, port) {
|
|
24
|
+
if (req.method !== "GET" && req.method !== "HEAD") return { status: 405, reason: "Method not allowed." };
|
|
25
|
+
// Origin-form only: an absolute request target (proxy style) could name another authority.
|
|
26
|
+
if (typeof req.url !== "string" || !req.url.startsWith("/")) return { status: 400, reason: "Bad request target." };
|
|
27
|
+
const ownHosts = new Set([`${DEV_SERVER_HOST}:${port}`, `localhost:${port}`]);
|
|
28
|
+
const ownOrigins = new Set([...ownHosts].map((host) => `http://${host}`));
|
|
29
|
+
// rawHeaders keeps duplicates that req.headers would silently collapse.
|
|
30
|
+
const headerValues = (name) => {
|
|
31
|
+
const values = [];
|
|
32
|
+
for (let i = 0; i < req.rawHeaders.length; i += 2) {
|
|
33
|
+
if (req.rawHeaders[i].toLowerCase() === name) values.push(req.rawHeaders[i + 1].trim().toLowerCase());
|
|
34
|
+
}
|
|
35
|
+
return values;
|
|
36
|
+
};
|
|
37
|
+
// Exactly one Host, and it is this server: a DNS-rebinding page arrives with its own host name.
|
|
38
|
+
// X-Forwarded-Host / Forwarded / Referer are never read.
|
|
39
|
+
const hosts = headerValues("host");
|
|
40
|
+
if (hosts.length !== 1 || !ownHosts.has(hosts[0])) {
|
|
41
|
+
return { status: 403, reason: `This dev server only answers http://${DEV_SERVER_HOST}:${port}.` };
|
|
42
|
+
}
|
|
43
|
+
// A browser that sends Origin must be on this server's own origin (cross-origin pages and `null` refused).
|
|
44
|
+
const origins = headerValues("origin");
|
|
45
|
+
if (origins.length > 1 || (origins.length === 1 && !ownOrigins.has(origins[0]))) {
|
|
46
|
+
return { status: 403, reason: `Cross-origin requests are not served; open http://${DEV_SERVER_HOST}:${port}.` };
|
|
47
|
+
}
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
50
|
+
|
|
14
51
|
/**
|
|
15
52
|
* Standalone dev server. For each page request it reads the local theme files and
|
|
16
53
|
* posts them to the platform's `/api/dev/render` endpoint, then injects a livereload
|
|
@@ -105,18 +142,29 @@ export function startDevServer({ dir, url, token, port, syncDraft = false, insta
|
|
|
105
142
|
}
|
|
106
143
|
|
|
107
144
|
const server = createServer(async (req, res) => {
|
|
145
|
+
// Before every branch: no SSE subscription, disk read or authenticated render for a refused request.
|
|
146
|
+
const refused = refusal(req, server.address()?.port);
|
|
147
|
+
if (refused) {
|
|
148
|
+
res.writeHead(refused.status, {
|
|
149
|
+
"content-type": "text/plain; charset=utf-8",
|
|
150
|
+
"cache-control": "no-store",
|
|
151
|
+
...(refused.status === 405 ? { allow: ALLOWED_METHODS } : {}),
|
|
152
|
+
});
|
|
153
|
+
res.end(`${refused.reason}\n`);
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
const head = req.method === "HEAD";
|
|
108
157
|
try {
|
|
109
158
|
const pathname = decodeURIComponent(new URL(req.url, "http://localhost").pathname);
|
|
110
159
|
|
|
111
|
-
// Livereload SSE channel.
|
|
160
|
+
// Livereload SSE channel — same-origin only (no CORS header): cross-origin views no longer subscribe.
|
|
112
161
|
if (pathname === LIVERELOAD_PATH) {
|
|
113
162
|
res.writeHead(200, {
|
|
114
163
|
"content-type": "text/event-stream",
|
|
115
164
|
"cache-control": "no-cache, no-transform",
|
|
116
|
-
connection: "keep-alive",
|
|
117
|
-
// Cross-origin iframe'ler (canlı-domain önizleme + editör) subscribe edebilsin.
|
|
118
|
-
"access-control-allow-origin": "*",
|
|
165
|
+
...(head ? {} : { connection: "keep-alive" }),
|
|
119
166
|
});
|
|
167
|
+
if (head) return void res.end();
|
|
120
168
|
res.write("retry: 1000\n\n");
|
|
121
169
|
clients.add(res);
|
|
122
170
|
req.on("close", () => clients.delete(res));
|
|
@@ -127,7 +175,14 @@ export function startDevServer({ dir, url, token, port, syncDraft = false, insta
|
|
|
127
175
|
const assetFile = safeAssetFile(dir, pathname);
|
|
128
176
|
if (assetFile) {
|
|
129
177
|
res.writeHead(200, { "content-type": contentTypeFor(assetFile), "cache-control": "no-store" });
|
|
130
|
-
res.end(readFileSync(assetFile));
|
|
178
|
+
res.end(head ? undefined : readFileSync(assetFile));
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// HEAD never spends an authenticated render: headers only.
|
|
183
|
+
if (head) {
|
|
184
|
+
res.writeHead(200, { "content-type": "text/html; charset=utf-8", "cache-control": "no-store" });
|
|
185
|
+
res.end();
|
|
131
186
|
return;
|
|
132
187
|
}
|
|
133
188
|
|
|
@@ -166,7 +221,7 @@ export function startDevServer({ dir, url, token, port, syncDraft = false, insta
|
|
|
166
221
|
onReload?.({ file, synced: syncDraft && !error, clients: clients.size, error });
|
|
167
222
|
});
|
|
168
223
|
|
|
169
|
-
server.listen(port);
|
|
224
|
+
server.listen(port, DEV_SERVER_HOST);
|
|
170
225
|
|
|
171
226
|
return {
|
|
172
227
|
server,
|
package/package.json
CHANGED