@decocms/blocks-cli 7.50.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.50.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,7 +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-pull-decofile": "./scripts/pull-decofile.ts",
20
+ "deco-sync-blocks-bot": "./scripts/sync-blocks-bot.ts",
21
21
  "deco-upgrade-6-to-7": "./scripts/upgrade-6-to-7.ts",
22
22
  "deco-reconcile": "./scripts/reconcile.ts"
23
23
  },
@@ -33,7 +33,7 @@
33
33
  "lint:unused": "knip"
34
34
  },
35
35
  "dependencies": {
36
- "@decocms/blocks": "7.50.0",
36
+ "@decocms/blocks": "7.50.1",
37
37
  "ts-morph": "^27.0.0",
38
38
  "tsx": "^4.22.5"
39
39
  },
@@ -9,7 +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 { generateContentSyncYml } from "./templates/content-sync-yml";
12
+ import { generateSyncBlocksBotYml } from "./templates/sync-blocks-bot-yml";
13
13
  import { generateMigrationPolicyPointerRule } from "./templates/cursor-rules";
14
14
  import { generateHooks } from "./templates/hooks";
15
15
  import { generateKnipConfig } from "./templates/knip-config";
@@ -133,11 +133,11 @@ export function scaffold(ctx: MigrationContext): void {
133
133
 
134
134
  // Daily content pull from the still-live storefront (`<origin>/.decofile` ->
135
135
  // `.deco/blocks` -> PR). Replaces the legacy cross-repo-PAT push sync; inert
136
- // until the operator sets the repo variable CONTENT_SYNC_ORIGIN.
136
+ // until the operator sets the repo variable SYNC_BLOCKS_ORIGIN.
137
137
  writeFile(
138
138
  ctx,
139
- ".github/workflows/content-sync.yml",
140
- generateContentSyncYml(CANONICAL_BUN_VERSION),
139
+ ".github/workflows/sync-blocks-bot.yml",
140
+ generateSyncBlocksBotYml(CANONICAL_BUN_VERSION),
141
141
  );
142
142
 
143
143
  // Server entry files (server.ts, worker-entry.ts, router.tsx, runtime.ts, context.ts)
@@ -26,7 +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/content-sync.yml",
29
+ ".github/workflows/sync-blocks-bot.yml",
30
30
  "tools/gates/no-suppressions.sh",
31
31
  "playwright.config.ts",
32
32
  "knip.config.ts",
@@ -1,10 +1,10 @@
1
1
  import { describe, expect, it } from "vitest";
2
2
  import { generateCiFiles } from "./ci-yml";
3
- import { generateContentSyncYml } from "./content-sync-yml";
4
3
  import { generateMainPushGuardYml } from "./main-push-guard-yml";
5
4
  import { generateParityYml } from "./parity-yml";
6
5
  import { generatePlaywrightFiles } from "./playwright-yml";
7
6
  import { generateReactDoctorYml } from "./react-doctor-yml";
7
+ import { generateSyncBlocksBotYml } from "./sync-blocks-bot-yml";
8
8
 
9
9
  describe("generateCiFiles", () => {
10
10
  const files = generateCiFiles("22.15.0", "1.3.5");
@@ -125,15 +125,15 @@ describe("generateParityYml", () => {
125
125
  });
126
126
  });
127
127
 
128
- describe("generateContentSyncYml", () => {
129
- const yml = generateContentSyncYml("bun@1.3.5");
128
+ describe("generateSyncBlocksBotYml", () => {
129
+ const yml = generateSyncBlocksBotYml("bun@1.3.5");
130
130
 
131
- it("pulls on a daily cron, gated on CONTENT_SYNC_ORIGIN", () => {
132
- expect(yml).toContain("name: content-sync");
131
+ it("pulls on a daily cron, gated on SYNC_BLOCKS_ORIGIN", () => {
132
+ expect(yml).toContain("name: sync-blocks-bot");
133
133
  expect(yml).toContain('BUN_VERSION: "1.3.5"'); // bun@ prefix stripped
134
134
  expect(yml).toContain('- cron: "0 6 * * *"');
135
- expect(yml).toContain("vars.CONTENT_SYNC_ORIGIN");
136
- expect(yml).toContain("scripts/pull-decofile.ts");
135
+ expect(yml).toContain("vars.SYNC_BLOCKS_ORIGIN");
136
+ expect(yml).toContain("scripts/sync-blocks-bot.ts");
137
137
  expect(yml).toContain("--fail-on-plaintext-secret");
138
138
  });
139
139
 
@@ -160,9 +160,20 @@ describe("generateContentSyncYml", () => {
160
160
  });
161
161
 
162
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/pull-decofile.ts");
164
- const pinned = generateContentSyncYml("1.3.5", "7.50.0");
165
- expect(pinned).toContain("bunx -y @decocms/blocks-cli@7.50.0 deco-pull-decofile");
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/);
166
169
  expect(pinned).not.toContain("node_modules/@decocms/blocks-cli");
167
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
+ });
168
179
  });
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Scaffolds `.github/workflows/content-sync.yml` — the secure content channel
2
+ * Scaffolds `.github/workflows/sync-blocks-bot.yml` — the secure content channel
3
3
  * from the still-live Fresh/Deno storefront into the migrated repo.
4
4
  *
5
5
  * Replaces the legacy push-based sync (a workflow in the *legacy* repo holding a
@@ -9,7 +9,7 @@
9
9
  *
10
10
  * Direction is inverted — this repo pulls, on its own schedule, with its own
11
11
  * `GITHUB_TOKEN`, and gates the result:
12
- * - `pull-decofile.ts` fetches `<origin>/.decofile` (public, unauthenticated)
12
+ * - `sync-blocks-bot.ts` fetches `<origin>/.decofile` (public, unauthenticated)
13
13
  * and writes `.deco/blocks/`, denying the `Site` block and anything holding
14
14
  * encrypted credentials, and aborting on a plaintext credential;
15
15
  * - a path guard fails the run if anything outside `.deco/blocks/` changed;
@@ -18,37 +18,44 @@
18
18
  * `pull_request` workflows, so gating on the PR's own CI would need a PAT
19
19
  * and would never fire on its own.
20
20
  *
21
- * Inert until the operator sets the repo variable `CONTENT_SYNC_ORIGIN` (same
21
+ * Inert until the operator sets the repo variable `SYNC_BLOCKS_ORIGIN` (same
22
22
  * one-knob pattern as parity.yml's `PARITY_PROD_URL`) — the job skips cleanly.
23
23
  *
24
- * Docs: docs/content-sync.md
24
+ * Docs: docs/sync-blocks-bot.md
25
25
  *
26
26
  * @param bunVersion Pinned bun version, in lockstep with package.json.
27
27
  * @param cliVersion Optional `@decocms/blocks-cli` version to run the pull
28
- * with, via `bunx`. Omit for a freshly scaffolded site: the site's own
29
- * installed copy is by definition in-version, so the local file path is used.
30
- * Pass a version for a site still on an older `@decocms/*` — pinning only the
31
- * sync step gets the script without bumping the runtime the site builds
32
- * against (blocks-cli pins `@decocms/blocks` exactly, so bumping the devDep
33
- * drags a second runtime version into the tree). Drop it when the site bumps.
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.
34
41
  */
35
- export function generateContentSyncYml(bunVersion: string, cliVersion?: string): string {
42
+ export function generateSyncBlocksBotYml(bunVersion: string, cliVersion?: string): string {
36
43
  const bun = bunVersion.replace(/^bun@/, "");
37
44
  const pullCmd = cliVersion
38
- ? `bunx -y @decocms/blocks-cli@${cliVersion} deco-pull-decofile`
39
- : "bunx tsx node_modules/@decocms/blocks-cli/scripts/pull-decofile.ts";
40
- return `name: content-sync
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
41
48
 
42
49
  # Puxa o conteúdo publicado na loja de produção (Fresh/Deno) para \`.deco/blocks\`
43
50
  # e abre um PR. Sem token cross-repo: o repo legado não tem permissão nenhuma
44
51
  # aqui — este repo busca sozinho, valida e só então mergeia.
45
52
  #
46
- # Configuração (uma vez): variável de repo \`CONTENT_SYNC_ORIGIN\` = origin da
53
+ # Configuração (uma vez): variável de repo \`SYNC_BLOCKS_ORIGIN\` = origin da
47
54
  # loja de produção (ex.: https://www.minhaloja.com.br). Sem ela o job pula limpo.
48
55
  # Para revisão humana em vez de merge automático, mude \`AUTO_MERGE\` para "false".
49
56
  #
50
57
  # Depois de ligar isto, APAGUE o workflow de push no repo legado e REVOGUE o
51
- # token cross-repo — o pull não fecha aquela porta sozinho. Ver docs/content-sync.md.
58
+ # token cross-repo — o pull não fecha aquela porta sozinho. Ver docs/sync-blocks-bot.md.
52
59
 
53
60
  on:
54
61
  schedule:
@@ -57,7 +64,7 @@ on:
57
64
  workflow_dispatch:
58
65
  inputs:
59
66
  origin:
60
- description: "Origin da loja de produção (sobrepõe CONTENT_SYNC_ORIGIN)"
67
+ description: "Origin da loja de produção (sobrepõe SYNC_BLOCKS_ORIGIN)"
61
68
  required: false
62
69
  prune:
63
70
  description: "Apagar blocos que não existem mais em produção"
@@ -75,7 +82,7 @@ permissions:
75
82
  # Publicações do CMS acontecem em rajada; uma sync por vez, sem cancelar a que
76
83
  # já está no gate de build.
77
84
  concurrency:
78
- group: content-sync
85
+ group: sync-blocks-bot
79
86
  cancel-in-progress: false
80
87
 
81
88
  env:
@@ -94,11 +101,11 @@ jobs:
94
101
  id: cfg
95
102
  env:
96
103
  INPUT_ORIGIN: \${{ github.event.inputs.origin }}
97
- VAR_ORIGIN: \${{ vars.CONTENT_SYNC_ORIGIN }}
104
+ VAR_ORIGIN: \${{ vars.SYNC_BLOCKS_ORIGIN }}
98
105
  run: |
99
106
  origin="\${INPUT_ORIGIN:-$VAR_ORIGIN}"
100
107
  if [ -z "$origin" ]; then
101
- echo "::notice::variável de repo CONTENT_SYNC_ORIGIN não configurada — content-sync inerte."
108
+ echo "::notice::variável de repo SYNC_BLOCKS_ORIGIN não configurada — sync-blocks-bot inerte."
102
109
  echo "skip=true" >> "$GITHUB_OUTPUT"
103
110
  else
104
111
  echo "origin=$origin" >> "$GITHUB_OUTPUT"
@@ -123,12 +130,22 @@ jobs:
123
130
  PRUNE: \${{ github.event.inputs.prune == 'false' && ' ' || '--prune' }}
124
131
  DRY_RUN: \${{ github.event.inputs.dry_run == 'true' && '--dry-run' || ' ' }}
125
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
126
136
  ${pullCmd} \\
127
137
  --origin "$ORIGIN" \\
128
138
  --out .deco/blocks \\
129
139
  --deny "$DENY_KEYS" \\
130
140
  --fail-on-plaintext-secret \\
131
- --github $PRUNE $DRY_RUN
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
+ }
132
149
 
133
150
  - name: Guard — só .deco/blocks pode mudar
134
151
  id: guard
@@ -137,19 +154,19 @@ jobs:
137
154
  {
138
155
  git -c core.quotepath=false diff --name-only HEAD
139
156
  git -c core.quotepath=false ls-files --others --exclude-standard
140
- } | sort -u > /tmp/content-sync-changed.txt
141
- offending="$(grep -v '^\\.deco/blocks/' /tmp/content-sync-changed.txt || true)"
157
+ } | sort -u > /tmp/sync-blocks-changed.txt
158
+ offending="$(grep -v '^\\.deco/blocks/' /tmp/sync-blocks-changed.txt || true)"
142
159
  if [ -n "$offending" ]; then
143
160
  echo "::error::a sync mexeu fora de .deco/blocks — abortando:"
144
161
  echo "$offending"
145
162
  exit 1
146
163
  fi
147
- if [ ! -s /tmp/content-sync-changed.txt ]; then
164
+ if [ ! -s /tmp/sync-blocks-changed.txt ]; then
148
165
  echo "::notice::conteúdo já está em dia, nada a sincronizar."
149
166
  echo "changed=false" >> "$GITHUB_OUTPUT"
150
167
  else
151
168
  echo "changed=true" >> "$GITHUB_OUTPUT"
152
- echo "$(wc -l < /tmp/content-sync-changed.txt) arquivo(s) de bloco alterado(s)"
169
+ echo "$(wc -l < /tmp/sync-blocks-changed.txt) arquivo(s) de bloco alterado(s)"
153
170
  fi
154
171
 
155
172
  # Gate de verdade. Roda AQUI porque PR aberto com GITHUB_TOKEN não dispara
@@ -164,7 +181,7 @@ jobs:
164
181
  GH_TOKEN: \${{ secrets.GITHUB_TOKEN }}
165
182
  ORIGIN: \${{ steps.cfg.outputs.origin }}
166
183
  run: |
167
- branch="content-sync/$(date -u +%Y-%m-%dT%H%M%SZ)"
184
+ branch="sync-blocks/$(date -u +%Y-%m-%dT%H%M%SZ)"
168
185
  git config user.name "github-actions[bot]"
169
186
  git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
170
187
  git checkout -b "$branch"
@@ -173,7 +190,7 @@ jobs:
173
190
  git push origin "$branch"
174
191
  url="$(gh pr create \\
175
192
  --title "chore(content): sync .deco/blocks de produção" \\
176
- --body "Conteúdo puxado de \\\`$ORIGIN/.decofile\\\` pelo workflow \\\`content-sync\\\`. Só \\\`.deco/blocks/**\\\` mudou (guard) e \\\`generate + build\\\` passou antes deste PR existir." \\
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." \\
177
194
  --head "$branch")"
178
195
  echo "PR: $url"
179
196
  if [ "$AUTO_MERGE" = "true" ]; then
@@ -1,3 +1,4 @@
1
+ import { execFileSync } from "node:child_process";
1
2
  import * as fs from "node:fs";
2
3
  import * as os from "node:os";
3
4
  import * as path from "node:path";
@@ -9,10 +10,10 @@ import {
9
10
  hasEncryptedSecretRef,
10
11
  matchesGlob,
11
12
  writeDecofileToDir,
12
- } from "./pull-decofile";
13
+ } from "./sync-blocks-bot";
13
14
 
14
15
  function tmpBlocksDir(files: Record<string, unknown> = {}): string {
15
- const dir = fs.mkdtempSync(path.join(os.tmpdir(), "pull-decofile-"));
16
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), "sync-blocks-bot-"));
16
17
  const out = path.join(dir, ".deco", "blocks");
17
18
  fs.mkdirSync(out, { recursive: true });
18
19
  for (const [file, content] of Object.entries(files)) {
@@ -97,7 +98,7 @@ describe("writeDecofileToDir", () => {
97
98
  });
98
99
 
99
100
  it("compares content, not bytes — minified or reordered locals are not churn", () => {
100
- const dir = fs.mkdtempSync(path.join(os.tmpdir(), "pull-decofile-"));
101
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), "sync-blocks-bot-"));
101
102
  const out = path.join(dir, "blocks");
102
103
  fs.mkdirSync(out, { recursive: true });
103
104
  // how the Studio daemon / old sync bot write it: one minified line, no newline
@@ -256,3 +257,20 @@ describe("fetchDecofile", () => {
256
257
  );
257
258
  });
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
+ });
@@ -11,7 +11,7 @@
11
11
  * live site (`GET <origin>/.decofile`, public and unauthenticated) on a daily
12
12
  * cron, materialises one file per block, and opens a PR. No cross-repo token,
13
13
  * no write permission handed to anyone, and the content passes a build gate
14
- * before reaching `main`. See `docs/content-sync.md`.
14
+ * before reaching `main`. See `docs/sync-blocks-bot.md`.
15
15
  *
16
16
  * Three filters decide what may be overwritten:
17
17
  * 1. `--deny <globs>` — deny by block key (default: the `Site` block).
@@ -25,8 +25,8 @@
25
25
  * committed into git.
26
26
  *
27
27
  * Usage (from a site root):
28
- * tsx pull-decofile.ts --origin https://www.minhaloja.com.br --prune
29
- * tsx pull-decofile.ts --url https://www.minhaloja.com.br/.decofile --dry-run --json
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
30
  *
31
31
  * Exit codes:
32
32
  * 0 — done (with or without changes)
@@ -36,6 +36,7 @@
36
36
 
37
37
  import * as fs from "node:fs";
38
38
  import * as path from "node:path";
39
+ import { fileURLToPath } from "node:url";
39
40
  import { decodeBlockNameWithPasses } from "./lib/blocks-dedupe";
40
41
 
41
42
  /** Block keys never overwritten by a sync unless `--deny` is overridden. */
@@ -447,7 +448,7 @@ function showHelp(): void {
447
448
  @decocms/blocks-cli — pull the production decofile into .deco/blocks/
448
449
 
449
450
  Usage:
450
- tsx pull-decofile.ts --origin https://www.minhaloja.com.br [options]
451
+ tsx sync-blocks-bot.ts --origin https://www.minhaloja.com.br [options]
451
452
 
452
453
  Options:
453
454
  --origin <url> Site origin; fetches <origin>/.decofile
@@ -494,7 +495,7 @@ async function main(): Promise<void> {
494
495
  process.exit(0);
495
496
  }
496
497
  if (!opts.url) {
497
- console.error("pull-decofile: --origin or --url is required (see --help)");
498
+ console.error("sync-blocks-bot: --origin or --url is required (see --help)");
498
499
  process.exit(2);
499
500
  }
500
501
 
@@ -502,7 +503,7 @@ async function main(): Promise<void> {
502
503
  try {
503
504
  fetched = await fetchDecofile(opts.url, { maxBytes: opts.maxBytes, timeoutMs: opts.timeoutMs });
504
505
  } catch (e) {
505
- console.error(`pull-decofile: ${(e as Error).message}`);
506
+ console.error(`sync-blocks-bot: ${(e as Error).message}`);
506
507
  process.exit(2);
507
508
  }
508
509
 
@@ -510,7 +511,7 @@ async function main(): Promise<void> {
510
511
  try {
511
512
  report = writeDecofileToDir(fetched.blocks, opts);
512
513
  } catch (e) {
513
- console.error(`pull-decofile: ${(e as Error).message}`);
514
+ console.error(`sync-blocks-bot: ${(e as Error).message}`);
514
515
  process.exit(2);
515
516
  }
516
517
  report.revision = fetched.revision;
@@ -533,25 +534,34 @@ async function main(): Promise<void> {
533
534
 
534
535
  if (report.plaintextSecrets.length > 0 && opts.failOnPlaintextSecret) {
535
536
  console.error(
536
- `pull-decofile: ${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.`,
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.`,
537
538
  );
538
539
  process.exit(1);
539
540
  }
540
541
  process.exit(0);
541
542
  }
542
543
 
543
- const isCjsEntry =
544
- typeof require !== "undefined" && typeof module !== "undefined" && require.main === module;
545
- let isEsmEntry = false;
546
- try {
547
- isEsmEntry =
548
- typeof process !== "undefined" &&
549
- Array.isArray(process.argv) &&
550
- process.argv[1] !== undefined &&
551
- import.meta.url === `file://${process.argv[1]}`;
552
- } catch {
553
- // ignore in CJS
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
+ }
554
563
  }
555
- if (isCjsEntry || isEsmEntry) {
564
+
565
+ if (isMainModule()) {
556
566
  void main();
557
567
  }