setup-notice-kv.mjs 6.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219
  1. import { readFileSync, writeFileSync } from 'node:fs';
  2. import { spawnSync } from 'node:child_process';
  3. import { fileURLToPath } from 'node:url';
  4. import { dirname, resolve } from 'node:path';
  5. const __dirname = dirname(fileURLToPath(import.meta.url));
  6. const workerConfigPath = resolve(__dirname, '../worker/wrangler.jsonc');
  7. const bindingName = 'NOTICE_STORE';
  8. function readConfig() {
  9. return readFileSync(workerConfigPath, 'utf8');
  10. }
  11. function getWorkerName(source) {
  12. return source.match(/"name"\s*:\s*"([^"]+)"/)?.[1] || '';
  13. }
  14. function getConfiguredNamespaceId(source) {
  15. const escapedBinding = bindingName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
  16. const pattern = new RegExp(`\\{[\\s\\S]*?"binding"\\s*:\\s*"${escapedBinding}"[\\s\\S]*?"id"\\s*:\\s*"([^"]*)"[\\s\\S]*?\\}`);
  17. const id = source.match(pattern)?.[1]?.trim() || '';
  18. return id && !id.includes('<') ? id : '';
  19. }
  20. function runWrangler(args) {
  21. const result = spawnSync('npx', ['wrangler', ...args], {
  22. encoding: 'utf8',
  23. shell: process.platform === 'win32',
  24. });
  25. const output = `${result.stdout || ''}\n${result.stderr || ''}`.trim();
  26. return {
  27. status: result.status ?? 1,
  28. output,
  29. };
  30. }
  31. function parseNamespaceId(output) {
  32. const patterns = [
  33. /id\s*=\s*"([^"]+)"/i,
  34. /"id"\s*:\s*"([^"]+)"/i,
  35. /id[:=]\s*([0-9a-f]{32})/i,
  36. ];
  37. for (const pattern of patterns) {
  38. const match = output.match(pattern);
  39. if (match?.[1]) {
  40. return match[1];
  41. }
  42. }
  43. return '';
  44. }
  45. function parseNamespaceList(output, workerName) {
  46. const preferredTitles = new Set([
  47. bindingName,
  48. workerName ? `${workerName}-${bindingName}` : '',
  49. ].filter(Boolean));
  50. let namespaces;
  51. try {
  52. namespaces = JSON.parse(output);
  53. } catch {
  54. const start = output.indexOf('[');
  55. const end = output.lastIndexOf(']');
  56. if (start === -1 || end === -1 || end <= start) {
  57. return parseTextNamespaceList(output, preferredTitles);
  58. }
  59. try {
  60. namespaces = JSON.parse(output.slice(start, end + 1));
  61. } catch {
  62. return parseTextNamespaceList(output, preferredTitles);
  63. }
  64. }
  65. const items = Array.isArray(namespaces) ? namespaces : [];
  66. const exact = items.find((item) => preferredTitles.has(String(item.title || '')));
  67. if (exact?.id) {
  68. return exact.id;
  69. }
  70. const suffix = items.find((item) => String(item.title || '').endsWith(`-${bindingName}`));
  71. if (suffix?.id) {
  72. return suffix.id;
  73. }
  74. const textId = parseTextNamespaceList(output, preferredTitles);
  75. if (textId) {
  76. return textId;
  77. }
  78. return '';
  79. }
  80. function parseTextNamespaceList(output, preferredTitles) {
  81. const lines = String(output || '').split(/\r?\n/);
  82. const hexIdPattern = /\b[0-9a-f]{32}\b/i;
  83. for (const title of preferredTitles) {
  84. for (const line of lines) {
  85. if (!line.includes(title)) {
  86. continue;
  87. }
  88. const id = line.match(hexIdPattern)?.[0] || '';
  89. if (id) {
  90. return id;
  91. }
  92. }
  93. }
  94. for (const line of lines) {
  95. const cells = line.split('│').map((cell) => cell.trim()).filter(Boolean);
  96. const id = cells.find((cell) => hexIdPattern.test(cell))?.match(hexIdPattern)?.[0] || '';
  97. if (!id) {
  98. continue;
  99. }
  100. const title = cells.find((cell) => preferredTitles.has(cell) || cell.endsWith(`-${bindingName}`));
  101. if (title) {
  102. return id;
  103. }
  104. }
  105. return '';
  106. }
  107. function updateWranglerConfig(namespaceId) {
  108. const source = readConfig();
  109. const escapedBinding = bindingName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
  110. const bindingObjectPattern = new RegExp(`(\\{[\\s\\S]*?"binding"\\s*:\\s*"${escapedBinding}"[\\s\\S]*?"id"\\s*:\\s*")[^"]*("[\\s\\S]*?\\})`);
  111. if (bindingObjectPattern.test(source)) {
  112. writeFileSync(workerConfigPath, source.replace(bindingObjectPattern, `$1${namespaceId}$2`), 'utf8');
  113. return;
  114. }
  115. const namespaceBlock = ` "kv_namespaces": [\n {\n "binding": "${bindingName}",\n "id": "${namespaceId}"\n }\n ]`;
  116. const existingKvPattern = /"kv_namespaces"\s*:\s*\[/;
  117. if (existingKvPattern.test(source)) {
  118. const updated = source.replace(existingKvPattern, `"kv_namespaces": [\n {\n "binding": "${bindingName}",\n "id": "${namespaceId}"\n },`);
  119. writeFileSync(workerConfigPath, updated, 'utf8');
  120. return;
  121. }
  122. const insertAt = source.lastIndexOf('\n}');
  123. if (insertAt === -1) {
  124. throw new Error('Unable to locate closing brace in wrangler.jsonc');
  125. }
  126. const updated = `${source.slice(0, insertAt)},\n${namespaceBlock}${source.slice(insertAt)}`;
  127. writeFileSync(workerConfigPath, updated, 'utf8');
  128. }
  129. function printCredentialHelp(output) {
  130. if (output) {
  131. console.error(output);
  132. }
  133. console.error([
  134. 'Unable to create or find Cloudflare KV namespace NOTICE_STORE.',
  135. 'For CI, set CLOUDFLARE_API_TOKEN and CLOUDFLARE_ACCOUNT_ID in the environment that runs analytics/worker deployment.',
  136. 'For local setup, run `npx wrangler login` or set CLOUDFLARE_API_TOKEN before `npm run setup:notice-kv`.',
  137. ].join('\n'));
  138. }
  139. const source = readConfig();
  140. const configuredId = getConfiguredNamespaceId(source);
  141. if (configuredId) {
  142. console.log(`NOTICE_STORE KV namespace already configured: ${configuredId}`);
  143. process.exit(0);
  144. }
  145. const envNamespaceId = String(process.env.NOTICE_STORE_ID || '').trim();
  146. if (envNamespaceId) {
  147. updateWranglerConfig(envNamespaceId);
  148. console.log(`NOTICE_STORE KV namespace configured from NOTICE_STORE_ID: ${envNamespaceId}`);
  149. process.exit(0);
  150. }
  151. const workerName = getWorkerName(source);
  152. const listResult = runWrangler(['kv', 'namespace', 'list']);
  153. if (listResult.status === 0) {
  154. const existingId = parseNamespaceList(listResult.output, workerName);
  155. if (existingId) {
  156. updateWranglerConfig(existingId);
  157. console.log(`NOTICE_STORE KV namespace reused: ${existingId}`);
  158. process.exit(0);
  159. }
  160. }
  161. const createResult = runWrangler(['kv', 'namespace', 'create', bindingName]);
  162. if (createResult.status !== 0) {
  163. if (/already exists/i.test(createResult.output)) {
  164. const retryListResult = runWrangler(['kv', 'namespace', 'list']);
  165. if (retryListResult.status === 0) {
  166. const existingId = parseNamespaceList(retryListResult.output, workerName);
  167. if (existingId) {
  168. updateWranglerConfig(existingId);
  169. console.log(`NOTICE_STORE KV namespace reused after create conflict: ${existingId}`);
  170. process.exit(0);
  171. }
  172. }
  173. }
  174. printCredentialHelp(createResult.output || listResult.output);
  175. process.exit(createResult.status || 1);
  176. }
  177. const namespaceId = parseNamespaceId(createResult.output);
  178. if (!namespaceId) {
  179. console.error(createResult.output);
  180. throw new Error('Unable to parse KV namespace id from Wrangler output.');
  181. }
  182. updateWranglerConfig(namespaceId);
  183. console.log(`NOTICE_STORE KV namespace created and configured: ${namespaceId}`);