@decocms/blocks-cli 7.49.0 → 7.50.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@decocms/blocks-cli",
3
- "version": "7.49.0",
3
+ "version": "7.50.1",
4
4
  "type": "module",
5
5
  "description": "Deco codegen (generate-blocks, generate-schema, generate-invoke) and Fresh-to-TanStack migration tooling",
6
6
  "repository": {
@@ -17,6 +17,7 @@
17
17
  "deco-audit-observability": "./scripts/audit-observability-config.ts",
18
18
  "deco-migrate-blocks-to-kv": "./scripts/migrate-blocks-to-kv.ts",
19
19
  "deco-sync-blocks-to-kv": "./scripts/sync-blocks-to-kv.ts",
20
+ "deco-sync-blocks-bot": "./scripts/sync-blocks-bot.ts",
20
21
  "deco-upgrade-6-to-7": "./scripts/upgrade-6-to-7.ts",
21
22
  "deco-reconcile": "./scripts/reconcile.ts"
22
23
  },
@@ -32,7 +33,7 @@
32
33
  "lint:unused": "knip"
33
34
  },
34
35
  "dependencies": {
35
- "@decocms/blocks": "7.49.0",
36
+ "@decocms/blocks": "7.50.1",
36
37
  "ts-morph": "^27.0.0",
37
38
  "tsx": "^4.22.5"
38
39
  },
@@ -9,6 +9,7 @@ import { generateCacheConfig } from "./templates/cache-config";
9
9
  import { generateCiFiles } from "./templates/ci-yml";
10
10
  import { generateCommerceInit } from "./templates/commerce-init";
11
11
  import { generateCommerceLoaders } from "./templates/commerce-loaders";
12
+ import { generateSyncBlocksBotYml } from "./templates/sync-blocks-bot-yml";
12
13
  import { generateMigrationPolicyPointerRule } from "./templates/cursor-rules";
13
14
  import { generateHooks } from "./templates/hooks";
14
15
  import { generateKnipConfig } from "./templates/knip-config";
@@ -130,6 +131,15 @@ export function scaffold(ctx: MigrationContext): void {
130
131
  // live storefront (@decocms/parity). Inert until PARITY_PROD_URL is set.
131
132
  writeFile(ctx, ".github/workflows/parity.yml", generateParityYml(ctx.siteName));
132
133
 
134
+ // Daily content pull from the still-live storefront (`<origin>/.decofile` ->
135
+ // `.deco/blocks` -> PR). Replaces the legacy cross-repo-PAT push sync; inert
136
+ // until the operator sets the repo variable SYNC_BLOCKS_ORIGIN.
137
+ writeFile(
138
+ ctx,
139
+ ".github/workflows/sync-blocks-bot.yml",
140
+ generateSyncBlocksBotYml(CANONICAL_BUN_VERSION),
141
+ );
142
+
133
143
  // Server entry files (server.ts, worker-entry.ts, router.tsx, runtime.ts, context.ts)
134
144
  writeMultiFile(ctx, generateServerEntry(ctx));
135
145
 
@@ -26,6 +26,7 @@ const REQUIRED_FILES = [
26
26
  ".github/workflows/playwright.yml",
27
27
  ".github/workflows/react-doctor.yml",
28
28
  ".github/workflows/parity.yml",
29
+ ".github/workflows/sync-blocks-bot.yml",
29
30
  "tools/gates/no-suppressions.sh",
30
31
  "playwright.config.ts",
31
32
  "knip.config.ts",
@@ -4,6 +4,7 @@ import { generateMainPushGuardYml } from "./main-push-guard-yml";
4
4
  import { generateParityYml } from "./parity-yml";
5
5
  import { generatePlaywrightFiles } from "./playwright-yml";
6
6
  import { generateReactDoctorYml } from "./react-doctor-yml";
7
+ import { generateSyncBlocksBotYml } from "./sync-blocks-bot-yml";
7
8
 
8
9
  describe("generateCiFiles", () => {
9
10
  const files = generateCiFiles("22.15.0", "1.3.5");
@@ -123,3 +124,56 @@ describe("generateParityYml", () => {
123
124
  expect(yml).toContain("secrets.ANTHROPIC_API_KEY"); // optional LLM key
124
125
  });
125
126
  });
127
+
128
+ describe("generateSyncBlocksBotYml", () => {
129
+ const yml = generateSyncBlocksBotYml("bun@1.3.5");
130
+
131
+ it("pulls on a daily cron, gated on SYNC_BLOCKS_ORIGIN", () => {
132
+ expect(yml).toContain("name: sync-blocks-bot");
133
+ expect(yml).toContain('BUN_VERSION: "1.3.5"'); // bun@ prefix stripped
134
+ expect(yml).toContain('- cron: "0 6 * * *"');
135
+ expect(yml).toContain("vars.SYNC_BLOCKS_ORIGIN");
136
+ expect(yml).toContain("scripts/sync-blocks-bot.ts");
137
+ expect(yml).toContain("--fail-on-plaintext-secret");
138
+ });
139
+
140
+ it("only ever needs GITHUB_TOKEN — no cross-repo credential", () => {
141
+ expect(yml).toContain("secrets.GITHUB_TOKEN");
142
+ expect(yml).not.toMatch(/secrets\.(?!GITHUB_TOKEN)[A-Z_]+/);
143
+ expect(yml).toContain("permissions:");
144
+ expect(yml).toContain("concurrency:");
145
+ });
146
+
147
+ it("guards the diff to .deco/blocks and builds before opening the PR", () => {
148
+ const guardIdx = yml.indexOf("Guard");
149
+ const buildIdx = yml.indexOf("Validar (generate + build)");
150
+ const prIdx = yml.indexOf("gh pr create");
151
+ expect(guardIdx).toBeGreaterThan(-1);
152
+ expect(guardIdx).toBeLessThan(buildIdx);
153
+ expect(buildIdx).toBeLessThan(prIdx);
154
+ expect(yml).toContain("grep -v '^\\.deco/blocks/'");
155
+ expect(yml).toContain("git add .deco/blocks");
156
+ });
157
+
158
+ it("is de-projectized — no real site/customer names", () => {
159
+ expect(yml).not.toMatch(/oficina|miess|colombo/i);
160
+ });
161
+
162
+ it("runs the site's own installed CLI, or a pinned one when given", () => {
163
+ expect(yml).toContain("bunx tsx node_modules/@decocms/blocks-cli/scripts/sync-blocks-bot.ts");
164
+ const pinned = generateSyncBlocksBotYml("1.3.5", "7.51.0");
165
+ // `npx --package=<pkg> <bin>`, never `bunx <pkg> <bin>` — the latter treats
166
+ // the bin name as an argument and silently runs deco-migrate instead.
167
+ expect(pinned).toContain("npx --yes --package=@decocms/blocks-cli@7.51.0 deco-sync-blocks-bot");
168
+ expect(pinned).not.toMatch(/bunx\s+(-y\s+)?@decocms\/blocks-cli/);
169
+ expect(pinned).not.toContain("node_modules/@decocms/blocks-cli");
170
+ });
171
+
172
+ it("cannot pass green having run the wrong tool", () => {
173
+ // pipefail so `| tee` doesn't swallow the plaintext-secret exit 1, plus a
174
+ // grep on the report that only this script emits.
175
+ expect(yml).toContain("set -o pipefail");
176
+ expect(yml).toContain("--json --github");
177
+ expect(yml).toContain(`grep -q '"remoteBlocks"'`);
178
+ });
179
+ });
@@ -0,0 +1,201 @@
1
+ /**
2
+ * Scaffolds `.github/workflows/sync-blocks-bot.yml` — the secure content channel
3
+ * from the still-live Fresh/Deno storefront into the migrated repo.
4
+ *
5
+ * Replaces the legacy push-based sync (a workflow in the *legacy* repo holding a
6
+ * cross-repo PAT, `rsync --delete` + `git push` straight into this repo's main).
7
+ * That token is a code-write door, not a content channel: whoever controls the
8
+ * legacy repo or the token can land arbitrary files here, unreviewed.
9
+ *
10
+ * Direction is inverted — this repo pulls, on its own schedule, with its own
11
+ * `GITHUB_TOKEN`, and gates the result:
12
+ * - `sync-blocks-bot.ts` fetches `<origin>/.decofile` (public, unauthenticated)
13
+ * and writes `.deco/blocks/`, denying the `Site` block and anything holding
14
+ * encrypted credentials, and aborting on a plaintext credential;
15
+ * - a path guard fails the run if anything outside `.deco/blocks/` changed;
16
+ * - `bun run generate && bun run build` runs IN THIS JOB, before the PR. It
17
+ * has to be here: a PR opened with `GITHUB_TOKEN` does not trigger
18
+ * `pull_request` workflows, so gating on the PR's own CI would need a PAT
19
+ * and would never fire on its own.
20
+ *
21
+ * Inert until the operator sets the repo variable `SYNC_BLOCKS_ORIGIN` (same
22
+ * one-knob pattern as parity.yml's `PARITY_PROD_URL`) — the job skips cleanly.
23
+ *
24
+ * Docs: docs/sync-blocks-bot.md
25
+ *
26
+ * @param bunVersion Pinned bun version, in lockstep with package.json.
27
+ * @param cliVersion Optional `@decocms/blocks-cli` version to run the pull
28
+ * with. Omit for a freshly scaffolded site: the site's own installed copy is
29
+ * by definition in-version, so the local file path is used. Pass a version
30
+ * for a site still on an older `@decocms/*` — pinning only the sync step gets
31
+ * the script without bumping the runtime the site builds against (blocks-cli
32
+ * pins `@decocms/blocks` exactly, so bumping the devDep drags a second
33
+ * runtime version into the tree). Drop it when the site bumps.
34
+ *
35
+ * GOTCHA: the pinned form uses `npx --package=<pkg> <bin>`, NOT
36
+ * `bunx <pkg> <bin>`. `bunx pkg@ver some-bin` treats `some-bin` as an
37
+ * *argument* and runs the package's first bin instead — in this package that
38
+ * is `deco-migrate`, which rewrites the whole site and exits 0 in dry-run, so
39
+ * the job goes green having run the wrong tool. Observed on a real run. The
40
+ * `--json`-report assertion below is the second line of defence.
41
+ */
42
+ export function generateSyncBlocksBotYml(bunVersion: string, cliVersion?: string): string {
43
+ const bun = bunVersion.replace(/^bun@/, "");
44
+ const pullCmd = cliVersion
45
+ ? `npx --yes --package=@decocms/blocks-cli@${cliVersion} deco-sync-blocks-bot`
46
+ : "bunx tsx node_modules/@decocms/blocks-cli/scripts/sync-blocks-bot.ts";
47
+ return `name: sync-blocks-bot
48
+
49
+ # Puxa o conteúdo publicado na loja de produção (Fresh/Deno) para \`.deco/blocks\`
50
+ # e abre um PR. Sem token cross-repo: o repo legado não tem permissão nenhuma
51
+ # aqui — este repo busca sozinho, valida e só então mergeia.
52
+ #
53
+ # Configuração (uma vez): variável de repo \`SYNC_BLOCKS_ORIGIN\` = origin da
54
+ # loja de produção (ex.: https://www.minhaloja.com.br). Sem ela o job pula limpo.
55
+ # Para revisão humana em vez de merge automático, mude \`AUTO_MERGE\` para "false".
56
+ #
57
+ # Depois de ligar isto, APAGUE o workflow de push no repo legado e REVOGUE o
58
+ # token cross-repo — o pull não fecha aquela porta sozinho. Ver docs/sync-blocks-bot.md.
59
+
60
+ on:
61
+ schedule:
62
+ # 06:00 UTC = 03:00 BRT, fora do horário de publicação do CMS.
63
+ - cron: "0 6 * * *"
64
+ workflow_dispatch:
65
+ inputs:
66
+ origin:
67
+ description: "Origin da loja de produção (sobrepõe SYNC_BLOCKS_ORIGIN)"
68
+ required: false
69
+ prune:
70
+ description: "Apagar blocos que não existem mais em produção"
71
+ type: boolean
72
+ default: true
73
+ dry_run:
74
+ description: "Só relatório, não escreve nem abre PR"
75
+ type: boolean
76
+ default: false
77
+
78
+ permissions:
79
+ contents: write
80
+ pull-requests: write
81
+
82
+ # Publicações do CMS acontecem em rajada; uma sync por vez, sem cancelar a que
83
+ # já está no gate de build.
84
+ concurrency:
85
+ group: sync-blocks-bot
86
+ cancel-in-progress: false
87
+
88
+ env:
89
+ BUN_VERSION: "${bun}"
90
+ AUTO_MERGE: "true"
91
+ # Chaves de bloco que a sync NUNCA sobrescreve. Blocos com secret encriptado
92
+ # já são protegidos por shape pelo script (as credenciais deste repo vivem no
93
+ # bloco de app dele, que não existe nesse layout em produção).
94
+ DENY_KEYS: "Site,site"
95
+
96
+ jobs:
97
+ sync:
98
+ runs-on: ubuntu-latest
99
+ steps:
100
+ - name: Resolve origin
101
+ id: cfg
102
+ env:
103
+ INPUT_ORIGIN: \${{ github.event.inputs.origin }}
104
+ VAR_ORIGIN: \${{ vars.SYNC_BLOCKS_ORIGIN }}
105
+ run: |
106
+ origin="\${INPUT_ORIGIN:-$VAR_ORIGIN}"
107
+ if [ -z "$origin" ]; then
108
+ echo "::notice::variável de repo SYNC_BLOCKS_ORIGIN não configurada — sync-blocks-bot inerte."
109
+ echo "skip=true" >> "$GITHUB_OUTPUT"
110
+ else
111
+ echo "origin=$origin" >> "$GITHUB_OUTPUT"
112
+ fi
113
+
114
+ - uses: actions/checkout@v4
115
+ if: steps.cfg.outputs.skip != 'true'
116
+
117
+ - uses: oven-sh/setup-bun@v2
118
+ if: steps.cfg.outputs.skip != 'true'
119
+ with:
120
+ bun-version: \${{ env.BUN_VERSION }}
121
+
122
+ - name: Install
123
+ if: steps.cfg.outputs.skip != 'true'
124
+ run: bun install --frozen-lockfile
125
+
126
+ - name: Pull decofile de produção
127
+ if: steps.cfg.outputs.skip != 'true'
128
+ env:
129
+ ORIGIN: \${{ steps.cfg.outputs.origin }}
130
+ PRUNE: \${{ github.event.inputs.prune == 'false' && ' ' || '--prune' }}
131
+ DRY_RUN: \${{ github.event.inputs.dry_run == 'true' && '--dry-run' || ' ' }}
132
+ run: |
133
+ # pipefail é obrigatório: sem ele o \`| tee\` mascara o exit 1 do gate
134
+ # de plaintext secret e o job passa mesmo tendo abortado o pull.
135
+ set -o pipefail
136
+ ${pullCmd} \\
137
+ --origin "$ORIGIN" \\
138
+ --out .deco/blocks \\
139
+ --deny "$DENY_KEYS" \\
140
+ --fail-on-plaintext-secret \\
141
+ --json --github $PRUNE $DRY_RUN | tee /tmp/sync-blocks-report.json
142
+ # Prova de que foi ESTE script que rodou, e que ele viu conteúdo. Sem
143
+ # isso, uma invocação errada que resolva para outro bin do pacote
144
+ # termina 0 e o job fica verde tendo rodado a ferramenta errada.
145
+ grep -q '"remoteBlocks"' /tmp/sync-blocks-report.json || {
146
+ echo "::error::o passo de pull não produziu o relatório esperado — comando errado?"
147
+ exit 1
148
+ }
149
+
150
+ - name: Guard — só .deco/blocks pode mudar
151
+ id: guard
152
+ if: steps.cfg.outputs.skip != 'true' && github.event.inputs.dry_run != 'true'
153
+ run: |
154
+ {
155
+ git -c core.quotepath=false diff --name-only HEAD
156
+ git -c core.quotepath=false ls-files --others --exclude-standard
157
+ } | sort -u > /tmp/sync-blocks-changed.txt
158
+ offending="$(grep -v '^\\.deco/blocks/' /tmp/sync-blocks-changed.txt || true)"
159
+ if [ -n "$offending" ]; then
160
+ echo "::error::a sync mexeu fora de .deco/blocks — abortando:"
161
+ echo "$offending"
162
+ exit 1
163
+ fi
164
+ if [ ! -s /tmp/sync-blocks-changed.txt ]; then
165
+ echo "::notice::conteúdo já está em dia, nada a sincronizar."
166
+ echo "changed=false" >> "$GITHUB_OUTPUT"
167
+ else
168
+ echo "changed=true" >> "$GITHUB_OUTPUT"
169
+ echo "$(wc -l < /tmp/sync-blocks-changed.txt) arquivo(s) de bloco alterado(s)"
170
+ fi
171
+
172
+ # Gate de verdade. Roda AQUI porque PR aberto com GITHUB_TOKEN não dispara
173
+ # o workflow de \`pull_request\` — gatear no CI do PR exigiria um PAT.
174
+ - name: Validar (generate + build)
175
+ if: steps.guard.outputs.changed == 'true'
176
+ run: bun run generate && bun run build
177
+
178
+ - name: Abrir PR (e mergear se AUTO_MERGE)
179
+ if: steps.guard.outputs.changed == 'true'
180
+ env:
181
+ GH_TOKEN: \${{ secrets.GITHUB_TOKEN }}
182
+ ORIGIN: \${{ steps.cfg.outputs.origin }}
183
+ run: |
184
+ branch="sync-blocks/$(date -u +%Y-%m-%dT%H%M%SZ)"
185
+ git config user.name "github-actions[bot]"
186
+ git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
187
+ git checkout -b "$branch"
188
+ git add .deco/blocks
189
+ git commit -m "chore(content): sync .deco/blocks de $ORIGIN"
190
+ git push origin "$branch"
191
+ url="$(gh pr create \\
192
+ --title "chore(content): sync .deco/blocks de produção" \\
193
+ --body "Conteúdo puxado de \\\`$ORIGIN/.decofile\\\` pelo workflow \\\`sync-blocks-bot\\\`. Só \\\`.deco/blocks/**\\\` mudou (guard) e \\\`generate + build\\\` passou antes deste PR existir." \\
194
+ --head "$branch")"
195
+ echo "PR: $url"
196
+ if [ "$AUTO_MERGE" = "true" ]; then
197
+ gh pr merge "$url" --squash --delete-branch || \\
198
+ echo "::notice::merge automático bloqueado (branch protection?) — PR aberto para revisão: $url"
199
+ fi
200
+ `;
201
+ }
@@ -0,0 +1,276 @@
1
+ import { execFileSync } from "node:child_process";
2
+ import * as fs from "node:fs";
3
+ import * as os from "node:os";
4
+ import * as path from "node:path";
5
+ import { afterEach, describe, expect, it, vi } from "vitest";
6
+ import {
7
+ DEFAULT_DENY,
8
+ fetchDecofile,
9
+ findPlaintextSecrets,
10
+ hasEncryptedSecretRef,
11
+ matchesGlob,
12
+ writeDecofileToDir,
13
+ } from "./sync-blocks-bot";
14
+
15
+ function tmpBlocksDir(files: Record<string, unknown> = {}): string {
16
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), "sync-blocks-bot-"));
17
+ const out = path.join(dir, ".deco", "blocks");
18
+ fs.mkdirSync(out, { recursive: true });
19
+ for (const [file, content] of Object.entries(files)) {
20
+ fs.writeFileSync(path.join(out, file), `${JSON.stringify(content, null, 2)}\n`);
21
+ }
22
+ return out;
23
+ }
24
+
25
+ const ls = (dir: string) => fs.readdirSync(dir).sort();
26
+ const read = (dir: string, file: string) =>
27
+ JSON.parse(fs.readFileSync(path.join(dir, file), "utf-8")) as Record<string, unknown>;
28
+
29
+ describe("matchesGlob", () => {
30
+ it("matches whole keys with a * wildcard", () => {
31
+ expect(matchesGlob("Site", "Site")).toBe(true);
32
+ expect(matchesGlob("Sitemap", "Site")).toBe(false);
33
+ expect(matchesGlob("pages-Home", "pages-*")).toBe(true);
34
+ expect(matchesGlob("deco-vtex", "deco-*")).toBe(true);
35
+ // dots are literal, not "any char"
36
+ expect(matchesGlob("axb", "a.b")).toBe(false);
37
+ });
38
+ });
39
+
40
+ describe("hasEncryptedSecretRef", () => {
41
+ it("finds a {name, encrypted} ref at any depth", () => {
42
+ expect(hasEncryptedSecretRef({ appKey: { name: "BANANA", encrypted: "3a714de1" } })).toBe(true);
43
+ expect(hasEncryptedSecretRef({ a: [{ b: { name: "X", encrypted: "y" } }] })).toBe(true);
44
+ expect(hasEncryptedSecretRef({ name: "X", encrypted: "" })).toBe(false);
45
+ expect(hasEncryptedSecretRef({ sections: [{ __resolveType: "site/x.tsx" }] })).toBe(false);
46
+ });
47
+ });
48
+
49
+ describe("findPlaintextSecrets", () => {
50
+ it("flags credential-shaped props holding a raw string", () => {
51
+ expect(findPlaintextSecrets({ appToken: "abcdefghijklmnop" })).toEqual(["appToken"]);
52
+ expect(findPlaintextSecrets({ nested: { api_key: "abcdefghijklmnop" } })).toEqual([
53
+ "nested.api_key",
54
+ ]);
55
+ expect(findPlaintextSecrets({ list: [{ password: "abcdefghijklmnop" }] })).toEqual([
56
+ "list[0].password",
57
+ ]);
58
+ });
59
+
60
+ it("ignores a bare `key` prop — CMS content is full of them", () => {
61
+ // real shape: every VTEX PLP loader block carries selectedFacets[].key
62
+ expect(
63
+ findPlaintextSecrets({ selectedFacets: [{ key: "productClusterIds", value: "140" }] }),
64
+ ).toEqual([]);
65
+ // …but a qualified key still counts
66
+ expect(findPlaintextSecrets({ apiKey: "abcdefghijklmnop" })).toEqual(["apiKey"]);
67
+ expect(findPlaintextSecrets({ "private-key": "abcdefghijklmnop" })).toEqual(["private-key"]);
68
+ });
69
+
70
+ it("does not flag encrypted refs, urls, prose or short values", () => {
71
+ expect(findPlaintextSecrets({ appKey: { name: "X", encrypted: "abcdefghijklmnop" } })).toEqual(
72
+ [],
73
+ );
74
+ expect(findPlaintextSecrets({ tokenUrl: "https://x.com/very/long/path" })).toEqual([]);
75
+ expect(findPlaintextSecrets({ secret: "short" })).toEqual([]);
76
+ expect(findPlaintextSecrets({ password: "uma frase com espacos" })).toEqual([]);
77
+ expect(findPlaintextSecrets({ apiKey: "{{ FROM_ENV_VAR_HERE }}" })).toEqual([]);
78
+ });
79
+ });
80
+
81
+ describe("writeDecofileToDir", () => {
82
+ it("writes one single-encoded file per block and skips unchanged ones", () => {
83
+ const out = tmpBlocksDir();
84
+ const remote = {
85
+ "pages-Home": { name: "Home", path: "/", sections: [] },
86
+ "pages-A B": { name: "A B", path: "/a-b", sections: [] },
87
+ };
88
+
89
+ const first = writeDecofileToDir(remote, { out });
90
+ expect(first.added.sort()).toEqual(["pages-A B", "pages-Home"]);
91
+ expect(ls(out)).toEqual(["pages-A%20B.json", "pages-Home.json"]);
92
+ expect(read(out, "pages-Home.json").path).toBe("/");
93
+
94
+ const second = writeDecofileToDir(remote, { out });
95
+ expect(second.unchanged).toBe(2);
96
+ expect(second.added).toEqual([]);
97
+ expect(second.updated).toEqual([]);
98
+ });
99
+
100
+ it("compares content, not bytes — minified or reordered locals are not churn", () => {
101
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), "sync-blocks-bot-"));
102
+ const out = path.join(dir, "blocks");
103
+ fs.mkdirSync(out, { recursive: true });
104
+ // how the Studio daemon / old sync bot write it: one minified line, no newline
105
+ fs.writeFileSync(path.join(out, "pages-Home.json"), '{"sections":[],"path":"/","name":"Home"}');
106
+
107
+ const report = writeDecofileToDir(
108
+ { "pages-Home": { name: "Home", path: "/", sections: [] } },
109
+ { out },
110
+ );
111
+ expect(report.unchanged).toBe(1);
112
+ expect(report.updated).toEqual([]);
113
+ // untouched: no reformat commit
114
+ expect(fs.readFileSync(path.join(out, "pages-Home.json"), "utf-8")).toBe(
115
+ '{"sections":[],"path":"/","name":"Home"}',
116
+ );
117
+
118
+ // a real content change does get written, pretty-printed
119
+ const changed = writeDecofileToDir({ "pages-Home": { name: "Home v2", path: "/" } }, { out });
120
+ expect(changed.updated).toEqual(["pages-Home"]);
121
+ expect(fs.readFileSync(path.join(out, "pages-Home.json"), "utf-8")).toContain('\n "name"');
122
+ });
123
+
124
+ it("denies keys by glob, defaulting to the Site block", () => {
125
+ const out = tmpBlocksDir({ "Site.json": { seo: { title: "local" } } });
126
+ const report = writeDecofileToDir(
127
+ { Site: { seo: { title: "prod" } }, "pages-Home": { path: "/" } },
128
+ { out },
129
+ );
130
+ expect(DEFAULT_DENY).toContain("Site");
131
+ expect(report.denied).toEqual(["Site"]);
132
+ expect(read(out, "Site.json").seo).toEqual({ title: "local" });
133
+
134
+ const custom = writeDecofileToDir({ "pages-Home": { path: "/x" } }, { out, deny: ["pages-*"] });
135
+ expect(custom.denied).toEqual(["pages-Home"]);
136
+ });
137
+
138
+ it("never overwrites a block that carries an encrypted secret", () => {
139
+ const out = tmpBlocksDir({
140
+ "deco-vtex.json": { account: "loja", appKey: { name: "KEY", encrypted: "local" } },
141
+ });
142
+ const report = writeDecofileToDir(
143
+ { "deco-vtex": { account: "outra", appKey: { name: "KEY", encrypted: "prod" } } },
144
+ { out },
145
+ );
146
+ expect(report.protectedSecretBlocks).toEqual(["deco-vtex"]);
147
+ expect(read(out, "deco-vtex.json").account).toBe("loja");
148
+
149
+ const forced = writeDecofileToDir(
150
+ { "deco-vtex": { account: "outra", appKey: { name: "KEY", encrypted: "prod" } } },
151
+ { out, allowSecretBlocks: true },
152
+ );
153
+ expect(forced.updated).toEqual(["deco-vtex"]);
154
+ });
155
+
156
+ it("overwrites a legacy double-encoded filename in place instead of duplicating it", () => {
157
+ const out = tmpBlocksDir({ "pages-A%2520B.json": { name: "old", path: "/a-b" } });
158
+ const report = writeDecofileToDir({ "pages-A B": { name: "new", path: "/a-b" } }, { out });
159
+ expect(report.updated).toEqual(["pages-A B"]);
160
+ expect(ls(out)).toEqual(["pages-A%2520B.json"]);
161
+ expect(read(out, "pages-A%2520B.json").name).toBe("new");
162
+ });
163
+
164
+ it("collapses colliding encodings onto the canonical name", () => {
165
+ const out = tmpBlocksDir({
166
+ "pages-A%2520B.json": { name: "stale", path: "/a-b" },
167
+ "pages-A%20B.json": { name: "also stale", path: "/a-b" },
168
+ });
169
+ const report = writeDecofileToDir({ "pages-A B": { name: "new", path: "/a-b" } }, { out });
170
+ expect(report.updated).toEqual(["pages-A B"]);
171
+ expect(ls(out)).toEqual(["pages-A%20B.json"]);
172
+ expect(read(out, "pages-A%20B.json").name).toBe("new");
173
+ });
174
+
175
+ it("prunes blocks absent upstream, but keeps denied and secret-bearing ones", () => {
176
+ const out = tmpBlocksDir({
177
+ "pages-Gone.json": { name: "gone", path: "/gone" },
178
+ "pages-Home.json": { name: "home", path: "/" },
179
+ "Site.json": { seo: {} },
180
+ "deco-vtex.json": { appKey: { name: "KEY", encrypted: "local" } },
181
+ });
182
+ const report = writeDecofileToDir(
183
+ { "pages-Home": { name: "home", path: "/" } },
184
+ {
185
+ out,
186
+ prune: true,
187
+ },
188
+ );
189
+ expect(report.removed).toEqual(["pages-Gone"]);
190
+ expect(report.denied).toEqual(["Site"]);
191
+ expect(report.protectedSecretBlocks).toEqual(["deco-vtex"]);
192
+ expect(ls(out)).toEqual(["Site.json", "deco-vtex.json", "pages-Home.json"]);
193
+ });
194
+
195
+ it("--dry-run touches nothing", () => {
196
+ const out = tmpBlocksDir({ "pages-Gone.json": { path: "/gone" } });
197
+ const report = writeDecofileToDir(
198
+ { "pages-Home": { path: "/" } },
199
+ {
200
+ out,
201
+ prune: true,
202
+ dryRun: true,
203
+ },
204
+ );
205
+ expect(report.added).toEqual(["pages-Home"]);
206
+ expect(report.removed).toEqual(["pages-Gone"]);
207
+ expect(ls(out)).toEqual(["pages-Gone.json"]);
208
+ });
209
+
210
+ it("skips non-object payload values and reports plaintext secrets", () => {
211
+ const out = tmpBlocksDir();
212
+ const report = writeDecofileToDir(
213
+ { broken: "not a block", leak: { appToken: "abcdefghijklmnop" } },
214
+ { out },
215
+ );
216
+ expect(report.skipped).toEqual(["broken"]);
217
+ expect(report.plaintextSecrets).toEqual(["leak.appToken"]);
218
+ });
219
+ });
220
+
221
+ describe("fetchDecofile", () => {
222
+ afterEach(() => vi.unstubAllGlobals());
223
+
224
+ const stub = (body: string, init: { status?: number; headers?: Record<string, string> } = {}) => {
225
+ vi.stubGlobal(
226
+ "fetch",
227
+ vi.fn(
228
+ async () =>
229
+ new Response(body, {
230
+ status: init.status ?? 200,
231
+ headers: { "content-type": "application/json", ...init.headers },
232
+ }),
233
+ ),
234
+ );
235
+ };
236
+
237
+ it("returns the parsed decofile plus the ETag revision", async () => {
238
+ stub(JSON.stringify({ "pages-Home": { path: "/" } }), { headers: { etag: '"abc123"' } });
239
+ const result = await fetchDecofile("https://x.com/.decofile");
240
+ expect(result.blocks).toEqual({ "pages-Home": { path: "/" } });
241
+ expect(result.revision).toBe('"abc123"');
242
+ });
243
+
244
+ it("rejects a non-200, a non-JSON content-type, a non-object payload and an oversized body", async () => {
245
+ stub("{}", { status: 500 });
246
+ await expect(fetchDecofile("https://x.com/.decofile")).rejects.toThrow(/responded 500/);
247
+
248
+ stub("<html>", { headers: { "content-type": "text/html" } });
249
+ await expect(fetchDecofile("https://x.com/.decofile")).rejects.toThrow(/expected JSON/);
250
+
251
+ stub("[]");
252
+ await expect(fetchDecofile("https://x.com/.decofile")).rejects.toThrow(/an array/);
253
+
254
+ stub(JSON.stringify({ a: { b: 1 } }));
255
+ await expect(fetchDecofile("https://x.com/.decofile", { maxBytes: 4 })).rejects.toThrow(
256
+ /over the 4 byte cap/,
257
+ );
258
+ });
259
+ });
260
+
261
+ describe("CLI entrypoint", () => {
262
+ // Regression: invoked through the package `bin`, argv[1] is the
263
+ // node_modules/.bin symlink while import.meta.url is the real file. A string
264
+ // compare of the two fails, main() never runs, and the CLI exits 0 having
265
+ // printed nothing — a silently green CI job. Exercise the symlink path.
266
+ it("runs when invoked through a bin symlink", () => {
267
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), "sync-blocks-bin-"));
268
+ const link = path.join(dir, "deco-sync-blocks-bot");
269
+ fs.symlinkSync(path.join(__dirname, "sync-blocks-bot.ts"), link);
270
+
271
+ const out = execFileSync(process.execPath, [require.resolve("tsx/cli"), link, "--help"], {
272
+ encoding: "utf-8",
273
+ });
274
+ expect(out).toContain("--fail-on-plaintext-secret");
275
+ });
276
+ });
@@ -0,0 +1,567 @@
1
+ #!/usr/bin/env tsx
2
+ /**
3
+ * @decocms/blocks-cli — pull the production decofile into `.deco/blocks/`
4
+ *
5
+ * Secure replacement for the legacy push-based content sync, where a workflow
6
+ * in the *legacy* Fresh repo held a cross-repo PAT and pushed straight into the
7
+ * migrated repo's `main` (`rsync --delete` + `git push`). That token is a code
8
+ * -write door into the new repo, not a content channel.
9
+ *
10
+ * This inverts the direction: the migrated repo *pulls* the decofile from the
11
+ * live site (`GET <origin>/.decofile`, public and unauthenticated) on a daily
12
+ * cron, materialises one file per block, and opens a PR. No cross-repo token,
13
+ * no write permission handed to anyone, and the content passes a build gate
14
+ * before reaching `main`. See `docs/sync-blocks-bot.md`.
15
+ *
16
+ * Three filters decide what may be overwritten:
17
+ * 1. `--deny <globs>` — deny by block key (default: the `Site` block).
18
+ * 2. encrypted-secret shape — any block carrying a `{name, encrypted}` secret
19
+ * ref anywhere in its tree is left alone. That is what protects the
20
+ * credentials the migration moves onto the new site's own app block
21
+ * (e.g. `deco-vtex`), which do not exist in that layout upstream.
22
+ * Opt out with `--allow-secret-blocks`.
23
+ * 3. `--fail-on-plaintext-secret` — aborts if an accepted block carries what
24
+ * looks like a *plaintext* credential, so a leak upstream is never
25
+ * committed into git.
26
+ *
27
+ * Usage (from a site root):
28
+ * tsx sync-blocks-bot.ts --origin https://www.minhaloja.com.br --prune
29
+ * tsx sync-blocks-bot.ts --url https://www.minhaloja.com.br/.decofile --dry-run --json
30
+ *
31
+ * Exit codes:
32
+ * 0 — done (with or without changes)
33
+ * 1 — `--fail-on-plaintext-secret` and at least one plaintext finding
34
+ * 2 — usage / network / payload validation error (nothing was written)
35
+ */
36
+
37
+ import * as fs from "node:fs";
38
+ import * as path from "node:path";
39
+ import { fileURLToPath } from "node:url";
40
+ import { decodeBlockNameWithPasses } from "./lib/blocks-dedupe";
41
+
42
+ /** Block keys never overwritten by a sync unless `--deny` is overridden. */
43
+ export const DEFAULT_DENY = ["Site", "site"];
44
+
45
+ const DEFAULT_MAX_BYTES = 64 * 1024 * 1024;
46
+ const DEFAULT_TIMEOUT_MS = 60_000;
47
+
48
+ /**
49
+ * Property names whose *string* value would be a credential in the clear.
50
+ * A bare `key` is deliberately NOT here: CMS content is full of `key` props
51
+ * that are nothing of the sort (`selectedFacets[].key` on every VTEX PLP
52
+ * loader — 331 false positives on a real site), and a gate that cries wolf is a
53
+ * gate nobody keeps on.
54
+ * `key` only counts when qualified (`apiKey`, `appKey`, `privateKey`, …).
55
+ */
56
+ const SECRET_PROP_RE =
57
+ /(?:^|_)(?:(?:api|app|access|private|public|client|secret|auth)_?keys?|tokens?|secrets?|passwords?|passwd|pwd)$/;
58
+
59
+ /** `appToken` -> `app_token`, so one snake_case regex covers both styles. */
60
+ function normalizeProp(prop: string): string {
61
+ return prop
62
+ .replace(/([a-z0-9])([A-Z])/g, "$1_$2")
63
+ .replace(/-/g, "_")
64
+ .toLowerCase();
65
+ }
66
+
67
+ export interface PullOptions {
68
+ /** Directory that holds one JSON file per block (usually `.deco/blocks`). */
69
+ out: string;
70
+ /** Glob patterns (`*` wildcard) matched against the block key. */
71
+ deny?: string[];
72
+ /** Overwrite blocks that carry an encrypted secret ref (default: false). */
73
+ allowSecretBlocks?: boolean;
74
+ /** Delete local blocks that no longer exist upstream (default: false). */
75
+ prune?: boolean;
76
+ /** Compute the report without touching the filesystem. */
77
+ dryRun?: boolean;
78
+ }
79
+
80
+ export interface PullReport {
81
+ added: string[];
82
+ updated: string[];
83
+ unchanged: number;
84
+ removed: string[];
85
+ /** Keys skipped by the deny glob. */
86
+ denied: string[];
87
+ /** Keys skipped because the local/remote block carries an encrypted secret. */
88
+ protectedSecretBlocks: string[];
89
+ /** Keys skipped because the payload value was not a JSON object. */
90
+ skipped: string[];
91
+ /** `<key>.<prop path>` of every plaintext credential found in a written block. */
92
+ plaintextSecrets: string[];
93
+ /** Blocks present in the remote payload. */
94
+ remoteBlocks: number;
95
+ revision?: string;
96
+ bytes?: number;
97
+ }
98
+
99
+ export interface FetchResult {
100
+ blocks: Record<string, unknown>;
101
+ revision?: string;
102
+ bytes: number;
103
+ }
104
+
105
+ // ---------------------------------------------------------------- pure helpers
106
+
107
+ /** `*`-only glob match against a whole block key. */
108
+ export function matchesGlob(key: string, pattern: string): boolean {
109
+ const rx = new RegExp(
110
+ `^${pattern.replace(/[.*+?^${}()|[\]\\]/g, (c) => (c === "*" ? "[\\s\\S]*" : `\\${c}`))}$`,
111
+ );
112
+ return rx.test(key);
113
+ }
114
+
115
+ function isPlainObject(value: unknown): value is Record<string, unknown> {
116
+ return typeof value === "object" && value !== null && !Array.isArray(value);
117
+ }
118
+
119
+ /**
120
+ * True iff the block carries a Deco encrypted-secret ref anywhere in its tree.
121
+ * Prod serves those as `{"name": "MY_TOKEN", "encrypted": "3a714de1c8…"}`.
122
+ */
123
+ export function hasEncryptedSecretRef(value: unknown): boolean {
124
+ if (Array.isArray(value)) return value.some(hasEncryptedSecretRef);
125
+ if (!isPlainObject(value)) return false;
126
+ if (typeof value.name === "string" && typeof value.encrypted === "string" && value.encrypted) {
127
+ return true;
128
+ }
129
+ return Object.values(value).some(hasEncryptedSecretRef);
130
+ }
131
+
132
+ /**
133
+ * Property paths whose name looks credential-shaped and whose value is a raw
134
+ * string — i.e. a secret in the clear rather than an `{name, encrypted}` ref.
135
+ * URLs and values with whitespace are excluded (endpoints, prose, templates).
136
+ */
137
+ export function findPlaintextSecrets(value: unknown, prefix = ""): string[] {
138
+ const found: string[] = [];
139
+ const walk = (node: unknown, at: string): void => {
140
+ if (Array.isArray(node)) {
141
+ for (const [i, item] of node.entries()) walk(item, `${at}[${i}]`);
142
+ return;
143
+ }
144
+ if (!isPlainObject(node)) return;
145
+ for (const [prop, child] of Object.entries(node)) {
146
+ const at2 = at ? `${at}.${prop}` : prop;
147
+ if (
148
+ typeof child === "string" &&
149
+ child.length >= 12 &&
150
+ SECRET_PROP_RE.test(normalizeProp(prop)) &&
151
+ !/\s/.test(child) &&
152
+ !/^https?:\/\//i.test(child) &&
153
+ !/^\{\{.*\}\}$/.test(child)
154
+ ) {
155
+ found.push(at2);
156
+ continue;
157
+ }
158
+ walk(child, at2);
159
+ }
160
+ };
161
+ walk(value, prefix);
162
+ return found;
163
+ }
164
+
165
+ /** Fully URL-decode a block key or filename stem, for cross-scheme matching. */
166
+ function canonicalKey(keyOrFile: string): string {
167
+ return decodeBlockNameWithPasses(keyOrFile).name;
168
+ }
169
+
170
+ /**
171
+ * Serialised on-disk form of a block: pretty-printed, so a PR diff shows the
172
+ * sections/props that actually changed instead of one 3 MB line.
173
+ */
174
+ function serialize(block: unknown): string {
175
+ return `${JSON.stringify(block, null, 2)}\n`;
176
+ }
177
+
178
+ /**
179
+ * Key-sorted JSON, for *comparison only*. Writers of `.deco/blocks` disagree on
180
+ * formatting (the Studio daemon and the old bot minify, a hand-made sync PR
181
+ * pretty-prints) and on key order, so a byte compare reports a diff on a block
182
+ * whose content is identical — 124 of 432 blocks on a real site's first run.
183
+ * Compare semantically, write only real content changes.
184
+ */
185
+ function stableStringify(value: unknown): string {
186
+ if (Array.isArray(value)) return `[${value.map(stableStringify).join(",")}]`;
187
+ if (!isPlainObject(value)) return JSON.stringify(value) ?? "null";
188
+ const entries = Object.keys(value)
189
+ .sort()
190
+ .map((k) => `${JSON.stringify(k)}:${stableStringify(value[k])}`);
191
+ return `{${entries.join(",")}}`;
192
+ }
193
+
194
+ // ------------------------------------------------------------------ fetch side
195
+
196
+ /** Download and validate the remote decofile. Throws on any bad payload. */
197
+ export async function fetchDecofile(
198
+ url: string,
199
+ opts: { maxBytes?: number; timeoutMs?: number } = {},
200
+ ): Promise<FetchResult> {
201
+ const maxBytes = opts.maxBytes ?? DEFAULT_MAX_BYTES;
202
+ const res = await fetch(url, {
203
+ redirect: "follow",
204
+ headers: { accept: "application/json" },
205
+ signal: AbortSignal.timeout(opts.timeoutMs ?? DEFAULT_TIMEOUT_MS),
206
+ });
207
+ if (!res.ok) throw new Error(`${url} responded ${res.status} ${res.statusText}`);
208
+
209
+ const contentType = res.headers.get("content-type") ?? "";
210
+ if (!contentType.includes("json")) {
211
+ throw new Error(`${url} served "${contentType || "no content-type"}", expected JSON`);
212
+ }
213
+ const declared = Number(res.headers.get("content-length") ?? Number.NaN);
214
+ if (Number.isFinite(declared) && declared > maxBytes) {
215
+ throw new Error(`${url} is ${declared} bytes, over the ${maxBytes} byte cap`);
216
+ }
217
+
218
+ const body = await res.text();
219
+ if (body.length > maxBytes) {
220
+ throw new Error(`${url} is ${body.length} bytes, over the ${maxBytes} byte cap`);
221
+ }
222
+
223
+ let parsed: unknown;
224
+ try {
225
+ parsed = JSON.parse(body);
226
+ } catch (e) {
227
+ throw new Error(`${url} did not return valid JSON: ${(e as Error).message}`);
228
+ }
229
+ if (!isPlainObject(parsed)) {
230
+ throw new Error(
231
+ `${url} returned ${Array.isArray(parsed) ? "an array" : typeof parsed}, expected a decofile object`,
232
+ );
233
+ }
234
+
235
+ return { blocks: parsed, revision: res.headers.get("etag") ?? undefined, bytes: body.length };
236
+ }
237
+
238
+ // ------------------------------------------------------------------ write side
239
+
240
+ /**
241
+ * Materialise `remote` into `opts.out`, one file per block.
242
+ *
243
+ * Filenames are `encodeURIComponent(key) + ".json"` — the single-decode scheme
244
+ * the runtime's `parseBlockId` expects. When a file for the same *logical* key
245
+ * already exists under a different encoding (the `deco-sync-bot` wrote
246
+ * double-encoded names), that existing file is overwritten in place instead of
247
+ * a second, colliding one being created.
248
+ */
249
+ export function writeDecofileToDir(remote: Record<string, unknown>, opts: PullOptions): PullReport {
250
+ const out = path.resolve(opts.out);
251
+ const deny = opts.deny ?? DEFAULT_DENY;
252
+ const report: PullReport = {
253
+ added: [],
254
+ updated: [],
255
+ unchanged: 0,
256
+ removed: [],
257
+ denied: [],
258
+ protectedSecretBlocks: [],
259
+ skipped: [],
260
+ plaintextSecrets: [],
261
+ remoteBlocks: Object.keys(remote).length,
262
+ };
263
+
264
+ fs.mkdirSync(out, { recursive: true });
265
+
266
+ // Index what is already on disk by fully-decoded key, so we overwrite legacy
267
+ // double-encoded filenames instead of duplicating them. A key can map to
268
+ // several files (the `deco-sync-bot` wrote `pages-A%2520B.json` where the
269
+ // manual sync wrote `pages-A%20B.json`).
270
+ const existingByKey = new Map<string, string[]>();
271
+ for (const file of fs.readdirSync(out)) {
272
+ if (!file.endsWith(".json")) continue;
273
+ const key = canonicalKey(file);
274
+ const list = existingByKey.get(key);
275
+ if (list) list.push(file);
276
+ else existingByKey.set(key, [file]);
277
+ }
278
+
279
+ const seen = new Set<string>();
280
+
281
+ for (const [key, block] of Object.entries(remote)) {
282
+ const canonical = canonicalKey(key);
283
+ seen.add(canonical);
284
+
285
+ if (deny.some((p) => matchesGlob(key, p) || matchesGlob(canonical, p))) {
286
+ report.denied.push(key);
287
+ continue;
288
+ }
289
+ if (!isPlainObject(block)) {
290
+ report.skipped.push(key);
291
+ continue;
292
+ }
293
+ if (!opts.allowSecretBlocks && hasEncryptedSecretRef(block)) {
294
+ report.protectedSecretBlocks.push(key);
295
+ continue;
296
+ }
297
+
298
+ // One existing file → write it in place (smallest diff, no rename churn).
299
+ // Several → converge on the canonical single-encoded name and drop the
300
+ // others: leaving a stale duplicate behind is not cosmetic, `pickWinner`
301
+ // in generate-blocks prefers the *more*-encoded filename, so the stale one
302
+ // would win the build.
303
+ const existing = existingByKey.get(canonical) ?? [];
304
+ const canonicalFile = `${encodeURIComponent(key)}.json`;
305
+ const file = existing.length === 1 ? existing[0] : canonicalFile;
306
+ const target = path.join(out, file);
307
+ // encodeURIComponent cannot emit a separator, but assert the boundary
308
+ // anyway: this writes to a git repo from a remote payload.
309
+ if (path.dirname(path.resolve(target)) !== out) {
310
+ throw new Error(`refusing to write block "${key}" outside ${out}`);
311
+ }
312
+
313
+ const secrets = findPlaintextSecrets(block);
314
+ for (const at of secrets) report.plaintextSecrets.push(`${key}.${at}`);
315
+
316
+ const duplicates = existing.filter((f) => f !== file);
317
+ if (!opts.dryRun) {
318
+ for (const dup of duplicates) fs.rmSync(path.join(out, dup));
319
+ }
320
+
321
+ const next = serialize(block);
322
+ const current = fs.existsSync(target) ? fs.readFileSync(target, "utf-8") : null;
323
+ let currentParsed: unknown;
324
+ try {
325
+ currentParsed = current === null ? undefined : JSON.parse(current);
326
+ } catch {
327
+ currentParsed = undefined; // unparseable local file — overwrite it
328
+ }
329
+ const sameContent =
330
+ current !== null && stableStringify(currentParsed) === stableStringify(block);
331
+ if (sameContent && duplicates.length === 0) {
332
+ report.unchanged++;
333
+ continue;
334
+ }
335
+ if (!opts.dryRun) fs.writeFileSync(target, next);
336
+ (current === null ? report.added : report.updated).push(key);
337
+ }
338
+
339
+ if (opts.prune) {
340
+ for (const [key, files] of existingByKey) {
341
+ if (seen.has(key)) continue;
342
+ if (deny.some((p) => matchesGlob(key, p))) {
343
+ report.denied.push(key);
344
+ continue;
345
+ }
346
+ // A local-only block holding credentials is site-owned (the migration put
347
+ // them there); upstream never had it, so its absence must not delete it.
348
+ if (!opts.allowSecretBlocks) {
349
+ const local = files.map((f) => {
350
+ try {
351
+ return JSON.parse(fs.readFileSync(path.join(out, f), "utf-8")) as unknown;
352
+ } catch {
353
+ return null;
354
+ }
355
+ });
356
+ if (local.some(hasEncryptedSecretRef)) {
357
+ report.protectedSecretBlocks.push(key);
358
+ continue;
359
+ }
360
+ }
361
+ if (!opts.dryRun) {
362
+ for (const f of files) fs.rmSync(path.join(out, f));
363
+ }
364
+ report.removed.push(key);
365
+ }
366
+ }
367
+
368
+ return report;
369
+ }
370
+
371
+ // -------------------------------------------------------------------- CLI
372
+
373
+ interface CliOptions extends PullOptions {
374
+ url?: string;
375
+ maxBytes: number;
376
+ timeoutMs: number;
377
+ failOnPlaintextSecret: boolean;
378
+ json: boolean;
379
+ github: boolean;
380
+ help: boolean;
381
+ }
382
+
383
+ function parseArgs(argv: string[]): CliOptions {
384
+ const opts: CliOptions = {
385
+ out: ".deco/blocks",
386
+ maxBytes: DEFAULT_MAX_BYTES,
387
+ timeoutMs: DEFAULT_TIMEOUT_MS,
388
+ failOnPlaintextSecret: false,
389
+ json: false,
390
+ github: false,
391
+ help: false,
392
+ };
393
+ for (let i = 0; i < argv.length; i++) {
394
+ const flag = argv[i];
395
+ switch (flag) {
396
+ case "--url":
397
+ opts.url = argv[++i];
398
+ break;
399
+ case "--origin": {
400
+ const origin = (argv[++i] ?? "").replace(/\/+$/, "");
401
+ opts.url = origin ? `${origin}/.decofile` : undefined;
402
+ break;
403
+ }
404
+ case "--out":
405
+ opts.out = argv[++i] ?? opts.out;
406
+ break;
407
+ case "--deny":
408
+ opts.deny = (argv[++i] ?? "")
409
+ .split(",")
410
+ .map((s) => s.trim())
411
+ .filter(Boolean);
412
+ break;
413
+ case "--allow-secret-blocks":
414
+ opts.allowSecretBlocks = true;
415
+ break;
416
+ case "--prune":
417
+ opts.prune = true;
418
+ break;
419
+ case "--dry-run":
420
+ opts.dryRun = true;
421
+ break;
422
+ case "--fail-on-plaintext-secret":
423
+ opts.failOnPlaintextSecret = true;
424
+ break;
425
+ case "--max-bytes":
426
+ opts.maxBytes = Number(argv[++i]);
427
+ break;
428
+ case "--timeout-ms":
429
+ opts.timeoutMs = Number(argv[++i]);
430
+ break;
431
+ case "--json":
432
+ opts.json = true;
433
+ break;
434
+ case "--github":
435
+ opts.github = true;
436
+ break;
437
+ case "--help":
438
+ case "-h":
439
+ opts.help = true;
440
+ break;
441
+ }
442
+ }
443
+ return opts;
444
+ }
445
+
446
+ function showHelp(): void {
447
+ console.log(`
448
+ @decocms/blocks-cli — pull the production decofile into .deco/blocks/
449
+
450
+ Usage:
451
+ tsx sync-blocks-bot.ts --origin https://www.minhaloja.com.br [options]
452
+
453
+ Options:
454
+ --origin <url> Site origin; fetches <origin>/.decofile
455
+ --url <url> Full decofile URL (alternative to --origin)
456
+ --out <dir> Blocks directory (default: .deco/blocks)
457
+ --deny <globs> Comma-separated key globs never overwritten
458
+ (default: ${DEFAULT_DENY.join(",")})
459
+ --allow-secret-blocks Also overwrite blocks holding encrypted secrets
460
+ --prune Delete local blocks absent upstream
461
+ --dry-run Report only, write nothing
462
+ --fail-on-plaintext-secret Exit 1 if a written block holds a raw credential
463
+ --max-bytes <n> Payload cap (default: ${DEFAULT_MAX_BYTES})
464
+ --timeout-ms <n> Fetch timeout (default: ${DEFAULT_TIMEOUT_MS})
465
+ --json Emit the report as JSON
466
+ --github Emit ::notice::/::error:: lines for Actions
467
+ --help, -h This message
468
+
469
+ Exit codes:
470
+ 0 done 1 plaintext secret gate 2 usage/network error
471
+ `);
472
+ }
473
+
474
+ function reportToText(report: PullReport): string {
475
+ const lines = [
476
+ `remote blocks: ${report.remoteBlocks}${report.bytes ? ` (${report.bytes} bytes)` : ""}${report.revision ? ` revision ${report.revision}` : ""}`,
477
+ `added ${report.added.length} updated ${report.updated.length} unchanged ${report.unchanged} removed ${report.removed.length}`,
478
+ `denied ${report.denied.length} secret-protected ${report.protectedSecretBlocks.length} skipped ${report.skipped.length}`,
479
+ ];
480
+ for (const [label, keys] of [
481
+ ["added", report.added],
482
+ ["updated", report.updated],
483
+ ["removed", report.removed],
484
+ ] as const) {
485
+ for (const key of keys.slice(0, 50)) lines.push(` ${label}: ${key}`);
486
+ if (keys.length > 50) lines.push(` ${label}: … and ${keys.length - 50} more`);
487
+ }
488
+ return lines.join("\n");
489
+ }
490
+
491
+ async function main(): Promise<void> {
492
+ const opts = parseArgs(process.argv.slice(2));
493
+ if (opts.help) {
494
+ showHelp();
495
+ process.exit(0);
496
+ }
497
+ if (!opts.url) {
498
+ console.error("sync-blocks-bot: --origin or --url is required (see --help)");
499
+ process.exit(2);
500
+ }
501
+
502
+ let fetched: FetchResult;
503
+ try {
504
+ fetched = await fetchDecofile(opts.url, { maxBytes: opts.maxBytes, timeoutMs: opts.timeoutMs });
505
+ } catch (e) {
506
+ console.error(`sync-blocks-bot: ${(e as Error).message}`);
507
+ process.exit(2);
508
+ }
509
+
510
+ let report: PullReport;
511
+ try {
512
+ report = writeDecofileToDir(fetched.blocks, opts);
513
+ } catch (e) {
514
+ console.error(`sync-blocks-bot: ${(e as Error).message}`);
515
+ process.exit(2);
516
+ }
517
+ report.revision = fetched.revision;
518
+ report.bytes = fetched.bytes;
519
+
520
+ process.stdout.write(
521
+ `${opts.json ? JSON.stringify({ url: opts.url, ...report }, null, 2) : reportToText(report)}\n`,
522
+ );
523
+
524
+ if (opts.github) {
525
+ process.stdout.write(
526
+ `::notice::decofile sync — +${report.added.length} ~${report.updated.length} -${report.removed.length} (${report.remoteBlocks} blocks upstream)\n`,
527
+ );
528
+ for (const at of report.plaintextSecrets) {
529
+ process.stdout.write(
530
+ `::error title=plaintext-secret::${at} looks like a credential in the clear\n`,
531
+ );
532
+ }
533
+ }
534
+
535
+ if (report.plaintextSecrets.length > 0 && opts.failOnPlaintextSecret) {
536
+ console.error(
537
+ `sync-blocks-bot: ${report.plaintextSecrets.length} plaintext credential(s) in the pulled content — refusing to commit it. Move them to encrypted secrets upstream, or deny the block with --deny.`,
538
+ );
539
+ process.exit(1);
540
+ }
541
+ process.exit(0);
542
+ }
543
+
544
+ /**
545
+ * Entry detection must resolve symlinks. Invoked through the package `bin`
546
+ * (`npx --package=@decocms/blocks-cli deco-sync-blocks-bot`), `process.argv[1]`
547
+ * is the `node_modules/.bin/...` symlink while `import.meta.url` is the real
548
+ * file — a plain string compare fails, `main()` never runs, and the CLI exits 0
549
+ * having printed nothing. Observed on a real CI run; the workflow's report
550
+ * assertion is what surfaced it.
551
+ */
552
+ function isMainModule(): boolean {
553
+ if (typeof require !== "undefined" && typeof module !== "undefined" && require.main === module) {
554
+ return true;
555
+ }
556
+ try {
557
+ const arg = process.argv?.[1];
558
+ if (!arg) return false;
559
+ return fs.realpathSync(arg) === fs.realpathSync(fileURLToPath(import.meta.url));
560
+ } catch {
561
+ return false;
562
+ }
563
+ }
564
+
565
+ if (isMainModule()) {
566
+ void main();
567
+ }