@chenpengfei/daily-brief 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. package/CHANGELOG.md +26 -0
  2. package/LICENSE +21 -0
  3. package/README.md +28 -0
  4. package/config/sources.example.yaml +20 -0
  5. package/dist/src/adapters/fixture.js +70 -0
  6. package/dist/src/adapters/github-trending.js +183 -0
  7. package/dist/src/adapters/index.js +5 -0
  8. package/dist/src/adapters/rss.js +156 -0
  9. package/dist/src/adapters/types.js +1 -0
  10. package/dist/src/adapters/x.js +115 -0
  11. package/dist/src/agent/daily-brief-agent.js +350 -0
  12. package/dist/src/agent/index.js +10 -0
  13. package/dist/src/agent/model-runtime-config.js +221 -0
  14. package/dist/src/agent/model-stage-runtime.js +63 -0
  15. package/dist/src/agent/signal-narrative.js +247 -0
  16. package/dist/src/agent/signal-selection-ranking.js +276 -0
  17. package/dist/src/agent/source-grounding-audit.js +148 -0
  18. package/dist/src/agent/source-grounding-repair.js +159 -0
  19. package/dist/src/agent/source-item-understanding.js +206 -0
  20. package/dist/src/agent/stage-contracts.js +205 -0
  21. package/dist/src/agent/stage-runner.js +66 -0
  22. package/dist/src/brief/daily-brief.js +234 -0
  23. package/dist/src/brief/index.js +1 -0
  24. package/dist/src/cli.js +531 -0
  25. package/dist/src/collection/collect.js +67 -0
  26. package/dist/src/collection/index.js +1 -0
  27. package/dist/src/config/credential-store.js +169 -0
  28. package/dist/src/config/date-key.js +25 -0
  29. package/dist/src/config/index.js +5 -0
  30. package/dist/src/config/model-config.js +123 -0
  31. package/dist/src/config/paths.js +20 -0
  32. package/dist/src/config/source-registry.js +48 -0
  33. package/dist/src/discord/delivery.js +84 -0
  34. package/dist/src/discord/index.js +1 -0
  35. package/dist/src/domain/index.js +2 -0
  36. package/dist/src/domain/source-item.js +21 -0
  37. package/dist/src/domain/source.js +93 -0
  38. package/dist/src/storage/agent-run-artifact.js +44 -0
  39. package/dist/src/storage/brief-archive.js +17 -0
  40. package/dist/src/storage/index.js +3 -0
  41. package/dist/src/storage/source-item-store.js +63 -0
  42. package/dist/src/workflow/index.js +1 -0
  43. package/dist/src/workflow/status.js +95 -0
  44. package/docs/operations.md +74 -0
  45. package/docs/release-workflow.md +220 -0
  46. package/docs/user-manual.md +146 -0
  47. package/package.json +65 -0
  48. package/templates/daily-brief.md +9 -0
  49. package/templates/discord-notification.md +7 -0
@@ -0,0 +1,531 @@
1
+ #!/usr/bin/env node
2
+ import { mkdir, readFile, realpath, writeFile } from "node:fs/promises";
3
+ import { stdin as processStdin, stdout as processStdout } from "node:process";
4
+ import { createInterface } from "node:readline/promises";
5
+ import { fileURLToPath, pathToFileURL } from "node:url";
6
+ import { deliverOnce, generateOnce, loginModelCredential, logoutModelCredential, readModelRuntimeConfig, runOnce, statusModelCredentials, toApiKeyCredential } from "./agent/index.js";
7
+ import { collectSources } from "./collection/index.js";
8
+ import { resolveConfiguredWebhookUrl } from "./discord/index.js";
9
+ import { defaultModelConfig, dateFromDateKey, formatDateKey, formatSourceRegistry, loadSourceRegistry, putCredential, readDeliveryConfig, readConfiguredTimezone, getCredential, resolveDailyBriefPaths, setSourceEnabled, validateSourceRegistry, writeDeliveryConfig, writeCredentialStore, writeModelConfig } from "./config/index.js";
10
+ import { getOperationalStatus } from "./workflow/index.js";
11
+ const consoleIo = {
12
+ stdout(line) {
13
+ console.log(line);
14
+ },
15
+ stderr(line) {
16
+ console.error(line);
17
+ },
18
+ async prompt(message) {
19
+ const reader = createInterface({ input: processStdin, output: processStdout });
20
+ try {
21
+ return await reader.question(`${message} `);
22
+ }
23
+ finally {
24
+ reader.close();
25
+ }
26
+ }
27
+ };
28
+ export async function runCli(args, io = consoleIo, env = process.env) {
29
+ const [command, subcommand, value] = args;
30
+ const workflowFlags = parseFlags(args.slice(1));
31
+ const options = {
32
+ ...optionsFromEnv(env),
33
+ ...readWorkflowDateOption(workflowFlags, env)
34
+ };
35
+ if (!command || command === "help" || command === "--help" || command === "-h") {
36
+ printHelp(io);
37
+ return;
38
+ }
39
+ if (command === "run-once") {
40
+ await assertWorkflowConfigured(options.sourceRegistryPath);
41
+ const result = await runOnce(options);
42
+ if (result.coreFailure) {
43
+ throw new Error(`Core Workflow Failure: ${result.coreFailure.kind}\n${result.coreFailure.message}`);
44
+ }
45
+ io.stdout(`Daily Brief archived: ${result.archivePath}`);
46
+ io.stdout(`Sources read: ${result.sourceCount}`);
47
+ io.stdout(`Source Items read: ${result.sourceItemCount}`);
48
+ io.stdout(`Discord delivery: ${result.delivery.status}`);
49
+ io.stdout(`Pi events: ${result.piEvents.join(", ")}`);
50
+ return;
51
+ }
52
+ if (command === "setup") {
53
+ await handleSetupCommand(args.slice(1), io, env);
54
+ return;
55
+ }
56
+ if (command === "collect") {
57
+ await assertWorkflowConfigured(options.sourceRegistryPath);
58
+ const result = await collectSources(options);
59
+ io.stdout(`Source Item Store: ${result.storePath || "(no items written)"}`);
60
+ for (const source of result.sources) {
61
+ io.stdout(`${source.status.padEnd(7)} ${source.sourceId} items=${source.itemCount} written=${source.writtenCount} duplicates=${source.skippedDuplicateCount}${source.reason ? ` reason=${source.reason}` : ""}`);
62
+ }
63
+ return;
64
+ }
65
+ if (command === "generate") {
66
+ const result = await generateOnce(options);
67
+ io.stdout(`Daily Brief archived: ${result.archivePath}`);
68
+ io.stdout(`Source Items read: ${result.sourceItemCount}`);
69
+ return;
70
+ }
71
+ if (command === "deliver") {
72
+ const result = await deliverOnce(options);
73
+ io.stdout(`Discord delivery: ${result.status}${"reason" in result ? ` (${result.reason})` : ""}`);
74
+ return;
75
+ }
76
+ if (command === "status") {
77
+ const status = await getOperationalStatus(options);
78
+ io.stdout(`${status.health}: ${status.message}`);
79
+ for (const failure of status.materialPartialFailures) {
80
+ io.stdout(`- ${failure}`);
81
+ }
82
+ return;
83
+ }
84
+ if (command === "sources") {
85
+ await handleSourcesCommand(subcommand, value, io, options.sourceRegistryPath);
86
+ return;
87
+ }
88
+ if (command === "model") {
89
+ await handleModelCommand(args.slice(1), io, env);
90
+ return;
91
+ }
92
+ if (command === "delivery") {
93
+ await handleDeliveryCommand(args.slice(1), io, env);
94
+ return;
95
+ }
96
+ throw new Error(`Unknown command: ${command}`);
97
+ }
98
+ function readWorkflowDateOption(flags, env) {
99
+ if (flags.date) {
100
+ if (!/^\d{4}-\d{2}-\d{2}$/.test(flags.date)) {
101
+ throw new Error("--date must use YYYY-MM-DD");
102
+ }
103
+ return { date: dateFromDateKey(flags.date), dateKey: flags.date };
104
+ }
105
+ const now = new Date();
106
+ const paths = resolveDailyBriefPaths(env);
107
+ const timezone = readConfiguredTimezone(paths.configPath) ?? env.TZ ?? Intl.DateTimeFormat().resolvedOptions().timeZone;
108
+ return { date: now, dateKey: formatDateKey(now, timezone) };
109
+ }
110
+ async function assertWorkflowConfigured(sourceRegistryPath) {
111
+ if (!(await exists(sourceRegistryPath))) {
112
+ throw new Error(`Daily Brief is not configured. Run daily-brief setup first. Missing Source Registry: ${sourceRegistryPath}`);
113
+ }
114
+ }
115
+ async function handleSourcesCommand(subcommand, value, io, sourceRegistryPath) {
116
+ if (subcommand === "list") {
117
+ io.stdout(formatSourceRegistry(await loadSourceRegistry(sourceRegistryPath)));
118
+ return;
119
+ }
120
+ if (subcommand === "edit") {
121
+ const path = sourceRegistryPath ?? resolveDailyBriefPaths().sourceRegistryPath;
122
+ io.stdout(`Source Registry: ${path}`);
123
+ io.stdout("Edit this YAML file, then run: daily-brief sources validate");
124
+ return;
125
+ }
126
+ if (subcommand === "validate") {
127
+ const path = sourceRegistryPath ?? resolveDailyBriefPaths().sourceRegistryPath;
128
+ try {
129
+ const registry = await validateSourceRegistry(path);
130
+ io.stdout(`Valid Source Registry: ${path}`);
131
+ io.stdout(`Sources: ${registry.sources.length}`);
132
+ return;
133
+ }
134
+ catch (error) {
135
+ const message = error instanceof Error ? error.message : String(error);
136
+ throw new Error(`Source Registry invalid: ${path}\n${message}`);
137
+ }
138
+ }
139
+ if (subcommand === "enable" || subcommand === "disable") {
140
+ if (!value) {
141
+ throw new Error(`sources ${subcommand} requires a Source id`);
142
+ }
143
+ const enabled = subcommand === "enable";
144
+ await setSourceEnabled(value, enabled, sourceRegistryPath);
145
+ io.stdout(`${enabled ? "Enabled" : "Disabled"} Source: ${value}`);
146
+ return;
147
+ }
148
+ throw new Error(`Unknown sources command: ${subcommand ?? "(missing)"}`);
149
+ }
150
+ function printHelp(io) {
151
+ io.stdout([
152
+ "Daily Brief Operational CLI",
153
+ "",
154
+ "Usage:",
155
+ " daily-brief setup [--force]",
156
+ " daily-brief run-once",
157
+ " daily-brief collect",
158
+ " daily-brief generate",
159
+ " daily-brief deliver",
160
+ " daily-brief status",
161
+ " daily-brief model configure [--provider <provider> --model <model> --credential-ref <ref>]",
162
+ " daily-brief model login [--provider openai-codex --credential-ref <ref>]",
163
+ " daily-brief model logout [--credential-ref <ref>]",
164
+ " daily-brief model status",
165
+ " daily-brief delivery configure --enabled true|false [--webhook-ref <ref> --webhook-url <url>]",
166
+ " daily-brief delivery status",
167
+ " daily-brief delivery test",
168
+ " daily-brief sources list",
169
+ " daily-brief sources edit",
170
+ " daily-brief sources validate",
171
+ " daily-brief sources enable <source-id>",
172
+ " daily-brief sources disable <source-id>"
173
+ ].join("\n"));
174
+ }
175
+ async function handleSetupCommand(args, io, env) {
176
+ const flags = parseFlags(args);
177
+ const force = flags.force === "true";
178
+ const paths = resolveDailyBriefPaths(env);
179
+ const timezone = env.TZ?.trim() || Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC";
180
+ await mkdir(paths.home, { recursive: true });
181
+ await mkdir(paths.sourceItemRoot, { recursive: true });
182
+ await mkdir(paths.agentRunRoot, { recursive: true });
183
+ await mkdir(paths.briefArchiveRoot, { recursive: true });
184
+ await writeIfNeeded(paths.configPath, [
185
+ `timezone: ${timezone}`,
186
+ "brief:",
187
+ " language: zh",
188
+ " maxSignals: 5",
189
+ "model:",
190
+ " provider: openai-codex",
191
+ " model: gpt-5.5",
192
+ " credentialRef: openai-codex.default",
193
+ "delivery:",
194
+ " enabled: false",
195
+ ""
196
+ ].join("\n"), force);
197
+ await writeIfNeeded(paths.sourceRegistryPath, defaultSourceRegistryExample(), force);
198
+ if (force || !(await exists(paths.authPath))) {
199
+ writeCredentialStore({ credentials: {} }, paths.authPath);
200
+ }
201
+ io.stdout(`Daily Brief home: ${paths.home}`);
202
+ io.stdout(`Daily Brief data: ${paths.dataHome}`);
203
+ io.stdout(`Timezone: ${timezone}`);
204
+ io.stdout(`Source Registry: ${paths.sourceRegistryPath}`);
205
+ io.stdout("Next: daily-brief sources validate");
206
+ io.stdout("Next: daily-brief model configure");
207
+ io.stdout("Optional: daily-brief delivery configure --enabled true --webhook-url <url>");
208
+ io.stdout("Readiness: config files present, Source Registry initialized, data directories writable, delivery disabled");
209
+ }
210
+ async function handleDeliveryCommand(args, io, env) {
211
+ const [subcommand, ...rest] = args;
212
+ const flags = parseFlags(rest);
213
+ const paths = resolveDailyBriefPaths(env);
214
+ if (subcommand === "configure") {
215
+ const enabled = flags.enabled === "true" || flags.enabled === "yes";
216
+ const webhookRef = flags["webhook-ref"] ?? "discord.default";
217
+ writeDeliveryConfig({ enabled, ...(enabled ? { webhookRef } : {}) }, paths.configPath);
218
+ if (enabled && flags["webhook-url"]) {
219
+ putCredential(webhookRef, { type: "webhook", provider: "discord", webhookUrl: flags["webhook-url"] }, paths.authPath);
220
+ }
221
+ io.stdout(`Discord delivery: ${enabled ? "enabled" : "disabled"}`);
222
+ if (enabled)
223
+ io.stdout(`Webhook Ref: ${webhookRef}`);
224
+ return;
225
+ }
226
+ if (subcommand === "status") {
227
+ const config = readDeliveryConfig(paths.configPath);
228
+ if (!config?.enabled) {
229
+ io.stdout("Discord delivery: disabled");
230
+ return;
231
+ }
232
+ const credential = config.webhookRef ? getDeliveryWebhookCredential(config.webhookRef, paths.authPath) : undefined;
233
+ io.stdout("Discord delivery: enabled");
234
+ io.stdout(`Webhook Ref: ${config.webhookRef ?? "(missing)"}`);
235
+ io.stdout(`Webhook: ${credential ? "<redacted>" : "(missing)"}`);
236
+ return;
237
+ }
238
+ if (subcommand === "test") {
239
+ const config = readDeliveryConfig(paths.configPath);
240
+ const credential = config?.webhookRef ? getDeliveryWebhookCredential(config.webhookRef, paths.authPath) : undefined;
241
+ if (!config?.enabled || !credential) {
242
+ throw new Error("delivery test requires enabled Discord delivery with a webhook credential");
243
+ }
244
+ const response = await (io.fetchImpl ?? fetch)(credential.webhookUrl, {
245
+ method: "POST",
246
+ headers: { "content-type": "application/json" },
247
+ body: JSON.stringify({ content: "Daily Brief delivery test" })
248
+ });
249
+ if (!response.ok) {
250
+ throw new Error(`Discord delivery test failed with status ${response.status}`);
251
+ }
252
+ io.stdout("Discord delivery test: sent");
253
+ return;
254
+ }
255
+ throw new Error(`Unknown delivery command: ${subcommand ?? "(missing)"}`);
256
+ }
257
+ function getDeliveryWebhookCredential(ref, authPath) {
258
+ const credential = getCredential(ref, authPath);
259
+ return credential?.type === "webhook" && credential.provider === "discord" ? credential : undefined;
260
+ }
261
+ async function writeIfNeeded(path, contents, force) {
262
+ if (!force && (await exists(path))) {
263
+ return;
264
+ }
265
+ await writeFile(path, contents, "utf8");
266
+ }
267
+ async function exists(path) {
268
+ try {
269
+ await readFile(path, "utf8");
270
+ return true;
271
+ }
272
+ catch (error) {
273
+ if (isNodeError(error) && error.code === "ENOENT") {
274
+ return false;
275
+ }
276
+ throw error;
277
+ }
278
+ }
279
+ function defaultSourceRegistryExample() {
280
+ return [
281
+ "# Example Source Registry for Daily Brief.",
282
+ "#",
283
+ "# User-specific Source Registry lives outside the repository, normally at:",
284
+ "# ~/.daily-brief/sources.yaml",
285
+ "",
286
+ "sources:",
287
+ " - id: github-trending-daily",
288
+ " platform: github",
289
+ " adapter: github-trending",
290
+ " target: https://github.com/trending?since=daily",
291
+ " enabled: true",
292
+ " notes: Site-wide daily GitHub Trending; Brief generation filters for Agent Architecture and AI Coding signals",
293
+ ""
294
+ ].join("\n");
295
+ }
296
+ async function handleModelCommand(args, io, env) {
297
+ const [subcommand, ...rest] = args;
298
+ const flags = parseFlags(rest);
299
+ if (subcommand === "configure") {
300
+ await configureModel(flags, io, env);
301
+ return;
302
+ }
303
+ if (subcommand === "status") {
304
+ const config = readModelRuntimeConfig(env);
305
+ io.stdout(`Provider: ${config.provider}`);
306
+ io.stdout(`Model: ${config.model}`);
307
+ io.stdout(`Credential Ref: ${config.credentialRef ?? "(none)"}`);
308
+ io.stdout(`Ready: ${config.ready ? "yes" : "no"}`);
309
+ for (const issue of config.issues) {
310
+ io.stdout(`- ${issue}`);
311
+ }
312
+ for (const credential of statusModelCredentials(env)) {
313
+ io.stdout(`Credential: ${credential.ref} provider=${credential.provider} type=${credential.type} secret=${credential.secret}`);
314
+ }
315
+ return;
316
+ }
317
+ if (subcommand === "login") {
318
+ const config = readModelRuntimeConfig(env);
319
+ const provider = readProviderFlag(flags.provider ?? config.provider ?? defaultModelConfig().provider);
320
+ const credentialRef = flags["credential-ref"] ?? config.credentialRef ?? defaultCredentialRef(provider);
321
+ if (!credentialRef) {
322
+ throw new Error("model login requires --credential-ref");
323
+ }
324
+ await loginModelCredential({ provider, credentialRef, io, env });
325
+ io.stdout(`Logged in credential: ${credentialRef}`);
326
+ return;
327
+ }
328
+ if (subcommand === "logout") {
329
+ const config = readModelRuntimeConfig(env);
330
+ const credentialRef = flags["credential-ref"] ?? rest[0] ?? config.credentialRef;
331
+ if (!credentialRef) {
332
+ throw new Error("model logout requires --credential-ref");
333
+ }
334
+ logoutModelCredential(credentialRef, env);
335
+ io.stdout(`Logged out credential: ${credentialRef}`);
336
+ return;
337
+ }
338
+ throw new Error(`Unknown model command: ${subcommand ?? "(missing)"}`);
339
+ }
340
+ async function configureModel(flags, io, env) {
341
+ const interactive = Object.keys(flags).length === 0;
342
+ const provider = readProviderFlag(flags.provider ??
343
+ (interactive ? await promptWithDefault(io, "Provider", defaultModelConfig().provider) : missingFlag("provider")));
344
+ const model = flags.model ?? (interactive ? await promptWithDefault(io, "Model", defaultModelForProvider(provider)) : missingFlag("model"));
345
+ const credentialRef = flags["credential-ref"] ??
346
+ (interactive ? await promptWithDefault(io, "Credential ref", defaultCredentialRef(provider) ?? "") : missingFlag("credential-ref"));
347
+ const baseUrl = flags["base-url"] ?? (provider === "openai-compatible" && interactive ? await promptWithDefault(io, "Base URL", "") : undefined);
348
+ const config = {
349
+ provider,
350
+ model,
351
+ credentialRef,
352
+ ...(baseUrl ? { baseUrl } : {})
353
+ };
354
+ writeModelConfig(config, resolveDailyBriefPaths(env).configPath);
355
+ if (flags["api-key"]) {
356
+ putCredential(credentialRef, toApiKeyCredential(provider, flags["api-key"]), resolveDailyBriefPaths(env).authPath);
357
+ }
358
+ io.stdout(`Model configured: ${provider}/${model}`);
359
+ io.stdout(`Credential Ref: ${credentialRef}`);
360
+ }
361
+ function parseFlags(args) {
362
+ const flags = {};
363
+ for (let index = 0; index < args.length; index += 1) {
364
+ const arg = args[index];
365
+ if (!arg?.startsWith("--")) {
366
+ continue;
367
+ }
368
+ const equalsIndex = arg.indexOf("=");
369
+ if (equalsIndex > 2) {
370
+ flags[arg.slice(2, equalsIndex)] = arg.slice(equalsIndex + 1);
371
+ continue;
372
+ }
373
+ const key = arg.slice(2);
374
+ const next = args[index + 1];
375
+ if (next && !next.startsWith("--")) {
376
+ flags[key] = next;
377
+ index += 1;
378
+ }
379
+ else {
380
+ flags[key] = "true";
381
+ }
382
+ }
383
+ return flags;
384
+ }
385
+ function readProviderFlag(value) {
386
+ const provider = value.trim().toLowerCase();
387
+ if (provider === "codex" || provider === "hermes") {
388
+ return "openai-codex";
389
+ }
390
+ if (provider === "faux") {
391
+ throw new Error("Unsupported model provider for installed CLI: faux is test-only");
392
+ }
393
+ if (provider === "openai-codex" || provider === "openai" || provider === "deepseek" || provider === "openai-compatible") {
394
+ return provider;
395
+ }
396
+ throw new Error(`Unsupported model provider: ${value}`);
397
+ }
398
+ function defaultModelForProvider(provider) {
399
+ if (provider === "openai-codex") {
400
+ return "gpt-5.5";
401
+ }
402
+ if (provider === "openai") {
403
+ return "gpt-4.1-mini";
404
+ }
405
+ if (provider === "deepseek") {
406
+ return "deepseek-chat";
407
+ }
408
+ if (provider === "openai-compatible") {
409
+ return "openai-compatible-model";
410
+ }
411
+ return "faux-daily-brief-renderer";
412
+ }
413
+ function defaultCredentialRef(provider) {
414
+ if (provider === "faux") {
415
+ return undefined;
416
+ }
417
+ if (provider === "openai") {
418
+ return "env:OPENAI_API_KEY";
419
+ }
420
+ if (provider === "deepseek") {
421
+ return "env:DEEPSEEK_API_KEY";
422
+ }
423
+ if (provider === "openai-compatible") {
424
+ return "env:OPENAI_API_KEY";
425
+ }
426
+ return defaultModelConfig().credentialRef;
427
+ }
428
+ async function promptWithDefault(io, label, defaultValue) {
429
+ if (!io.prompt) {
430
+ throw new Error(`model configure requires --${label.toLowerCase().replaceAll(" ", "-")}`);
431
+ }
432
+ const answer = await io.prompt(`${label}${defaultValue ? ` (${defaultValue})` : ""}:`);
433
+ return answer.trim() || defaultValue;
434
+ }
435
+ function missingFlag(name) {
436
+ throw new Error(`model configure requires --${name}`);
437
+ }
438
+ function optionsFromEnv(env) {
439
+ const paths = resolveDailyBriefPaths(env);
440
+ const discordWebhookUrl = resolveConfiguredWebhookUrl(env);
441
+ return {
442
+ sourceRegistryPath: paths.sourceRegistryPath,
443
+ sourceItemRoot: paths.sourceItemRoot,
444
+ agentRunRoot: paths.agentRunRoot,
445
+ archiveRoot: paths.briefArchiveRoot,
446
+ modelRuntimeEnv: env,
447
+ ...(discordWebhookUrl ? { discordWebhookUrl } : {}),
448
+ ...(env.DAILY_BRIEF_DISCORD_TEMPLATE_PATH ? { discordTemplatePath: env.DAILY_BRIEF_DISCORD_TEMPLATE_PATH } : {})
449
+ };
450
+ }
451
+ export async function loadDotenvFile(path = ".env", env = process.env) {
452
+ let contents;
453
+ try {
454
+ contents = await readFile(path, "utf8");
455
+ }
456
+ catch (error) {
457
+ if (isNodeError(error) && error.code === "ENOENT") {
458
+ return;
459
+ }
460
+ throw error;
461
+ }
462
+ for (const line of contents.split("\n")) {
463
+ const entry = parseDotenvLine(line);
464
+ if (!entry || env[entry.key] !== undefined) {
465
+ continue;
466
+ }
467
+ env[entry.key] = entry.value;
468
+ }
469
+ }
470
+ function parseDotenvLine(line) {
471
+ const trimmed = line.trim();
472
+ if (trimmed.length === 0 || trimmed.startsWith("#")) {
473
+ return undefined;
474
+ }
475
+ const equalsIndex = trimmed.indexOf("=");
476
+ if (equalsIndex <= 0) {
477
+ return undefined;
478
+ }
479
+ const key = trimmed.slice(0, equalsIndex).trim();
480
+ const rawValue = trimmed.slice(equalsIndex + 1).trim();
481
+ if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) {
482
+ return undefined;
483
+ }
484
+ return { key, value: unquoteDotenvValue(rawValue) };
485
+ }
486
+ function unquoteDotenvValue(value) {
487
+ if (value.length >= 2) {
488
+ const quote = value[0];
489
+ const last = value[value.length - 1];
490
+ if ((quote === "\"" || quote === "'") && last === quote) {
491
+ return value.slice(1, -1);
492
+ }
493
+ }
494
+ return value;
495
+ }
496
+ function isNodeError(error) {
497
+ return error instanceof Error && "code" in error;
498
+ }
499
+ export async function isCliEntrypoint(argvPath, moduleUrl = import.meta.url) {
500
+ if (!argvPath) {
501
+ return false;
502
+ }
503
+ if (moduleUrl === pathToFileURL(argvPath).href) {
504
+ return true;
505
+ }
506
+ try {
507
+ const [realArgvPath, realModulePath] = await Promise.all([realpath(argvPath), realpath(fileURLToPath(moduleUrl))]);
508
+ return realArgvPath === realModulePath;
509
+ }
510
+ catch {
511
+ return false;
512
+ }
513
+ }
514
+ isCliEntrypoint(process.argv[1])
515
+ .then(async (isEntrypoint) => {
516
+ if (!isEntrypoint) {
517
+ return false;
518
+ }
519
+ await loadDotenvFile();
520
+ await runCli(process.argv.slice(2));
521
+ return true;
522
+ })
523
+ .then((didRun) => {
524
+ if (didRun) {
525
+ process.exit(0);
526
+ }
527
+ })
528
+ .catch((error) => {
529
+ consoleIo.stderr(error instanceof Error ? error.message : String(error));
530
+ process.exit(1);
531
+ });
@@ -0,0 +1,67 @@
1
+ import { fixtureFetchAdapter, githubTrendingFetchAdapter, rssFetchAdapter, xFetchAdapter } from "../adapters/index.js";
2
+ import { loadSourceRegistry } from "../config/index.js";
3
+ import { appendSourceItems } from "../storage/index.js";
4
+ export async function collectSources(options = {}) {
5
+ const date = options.date ?? new Date();
6
+ const fetchedAt = options.fetchedAt ?? new Date();
7
+ const registry = await loadSourceRegistry(options.sourceRegistryPath);
8
+ const adapters = options.adapters ?? defaultFetchAdapters();
9
+ const results = [];
10
+ let storePath = "";
11
+ for (const source of registry.sources) {
12
+ if (!source.enabled) {
13
+ results.push({
14
+ sourceId: source.id,
15
+ status: "skipped",
16
+ itemCount: 0,
17
+ writtenCount: 0,
18
+ skippedDuplicateCount: 0,
19
+ reason: "Source disabled"
20
+ });
21
+ continue;
22
+ }
23
+ const adapter = adapters[source.adapter];
24
+ if (!adapter) {
25
+ results.push({
26
+ sourceId: source.id,
27
+ status: "failed",
28
+ itemCount: 0,
29
+ writtenCount: 0,
30
+ skippedDuplicateCount: 0,
31
+ reason: `Fetch Adapter not registered: ${source.adapter}`
32
+ });
33
+ continue;
34
+ }
35
+ try {
36
+ const items = await adapter.fetch(source, { fetchedAt, collectionDate: date });
37
+ const appendResult = await appendSourceItems(items, date, options.sourceItemRoot, options.dateKey);
38
+ storePath = appendResult.path;
39
+ results.push({
40
+ sourceId: source.id,
41
+ status: "success",
42
+ itemCount: items.length,
43
+ writtenCount: appendResult.written.length,
44
+ skippedDuplicateCount: appendResult.skipped.length
45
+ });
46
+ }
47
+ catch (error) {
48
+ results.push({
49
+ sourceId: source.id,
50
+ status: "failed",
51
+ itemCount: 0,
52
+ writtenCount: 0,
53
+ skippedDuplicateCount: 0,
54
+ reason: error instanceof Error ? error.message : String(error)
55
+ });
56
+ }
57
+ }
58
+ return { storePath, sources: results };
59
+ }
60
+ export function defaultFetchAdapters() {
61
+ return {
62
+ [fixtureFetchAdapter.name]: fixtureFetchAdapter,
63
+ [githubTrendingFetchAdapter.name]: githubTrendingFetchAdapter,
64
+ [rssFetchAdapter.name]: rssFetchAdapter,
65
+ [xFetchAdapter.name]: xFetchAdapter
66
+ };
67
+ }
@@ -0,0 +1 @@
1
+ export * from "./collect.js";