chapterjs 0.1.4 → 0.2.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.
Files changed (82) hide show
  1. package/dist/cli/build.js +9 -10
  2. package/dist/cli/dev.js +29 -16
  3. package/dist/cli/project.d.ts +38 -25
  4. package/dist/cli/project.js +199 -134
  5. package/dist/cli/update-check.d.ts +42 -0
  6. package/dist/cli/update-check.js +125 -0
  7. package/dist/commands/command.d.ts +25 -11
  8. package/dist/commands/command.js +5 -5
  9. package/dist/commands/{convention.d.ts → declaration.d.ts} +24 -11
  10. package/dist/commands/{convention.js → declaration.js} +61 -33
  11. package/dist/commands/tree.d.ts +8 -4
  12. package/dist/commands/tree.js +16 -6
  13. package/dist/components/button.d.ts +13 -13
  14. package/dist/components/button.js +9 -11
  15. package/dist/components/component.d.ts +1 -1
  16. package/dist/components/component.js +1 -1
  17. package/dist/components/custom-id.d.ts +4 -4
  18. package/dist/components/custom-id.js +12 -12
  19. package/dist/components/declaration.d.ts +42 -0
  20. package/dist/components/{convention.js → declaration.js} +76 -73
  21. package/dist/components/declared.d.ts +29 -0
  22. package/dist/components/declared.js +49 -0
  23. package/dist/components/embed.d.ts +13 -12
  24. package/dist/components/embed.js +6 -6
  25. package/dist/components/instance.d.ts +3 -3
  26. package/dist/components/instance.js +2 -2
  27. package/dist/components/modal.d.ts +11 -10
  28. package/dist/components/modal.js +7 -8
  29. package/dist/components/router.d.ts +3 -2
  30. package/dist/components/router.js +14 -19
  31. package/dist/components/select.d.ts +14 -12
  32. package/dist/components/select.js +4 -4
  33. package/dist/events/declaration.d.ts +16 -0
  34. package/dist/events/declaration.js +80 -0
  35. package/dist/events/event.d.ts +42 -5
  36. package/dist/events/event.js +25 -11
  37. package/dist/events/registry.d.ts +1 -10
  38. package/dist/events/router.d.ts +3 -2
  39. package/dist/index.d.ts +11 -9
  40. package/dist/index.js +2 -0
  41. package/dist/loader/generated.d.ts +5 -32
  42. package/dist/loader/generated.js +21 -55
  43. package/dist/loader/loader.d.ts +57 -44
  44. package/dist/loader/loader.js +73 -89
  45. package/dist/main.d.ts +0 -1
  46. package/dist/main.js +2 -3
  47. package/dist/messages/commands.d.ts +3 -3
  48. package/dist/messages/commands.js +29 -10
  49. package/dist/messages/declaration.d.ts +89 -0
  50. package/dist/messages/declaration.js +455 -0
  51. package/dist/messages/messages.d.ts +65 -43
  52. package/dist/messages/messages.js +11 -10
  53. package/dist/messages/phrases.js +1 -1
  54. package/dist/messages/translate.d.ts +4 -4
  55. package/dist/messages/translate.js +4 -4
  56. package/dist/presence/declaration.d.ts +19 -0
  57. package/dist/presence/{convention.js → declaration.js} +13 -28
  58. package/dist/presence/presence.d.ts +7 -7
  59. package/dist/presence/presence.js +5 -5
  60. package/dist/structures/context.d.ts +1 -1
  61. package/dist/structures/interaction.d.ts +2 -2
  62. package/dist/structures/interaction.js +3 -3
  63. package/dist/structures/payload.d.ts +4 -4
  64. package/dist/structures/payload.js +4 -4
  65. package/dist/tasks/declaration.d.ts +16 -0
  66. package/dist/tasks/{convention.js → declaration.js} +14 -36
  67. package/dist/tasks/scheduler.d.ts +3 -1
  68. package/dist/tasks/scheduler.js +4 -4
  69. package/dist/tasks/task.d.ts +8 -9
  70. package/dist/tasks/task.js +6 -7
  71. package/package.json +1 -1
  72. package/dist/components/convention.d.ts +0 -35
  73. package/dist/components/file.d.ts +0 -30
  74. package/dist/components/file.js +0 -52
  75. package/dist/events/convention.d.ts +0 -25
  76. package/dist/events/convention.js +0 -97
  77. package/dist/events/types.d.ts +0 -7
  78. package/dist/events/types.js +0 -48
  79. package/dist/messages/convention.d.ts +0 -89
  80. package/dist/messages/convention.js +0 -347
  81. package/dist/presence/convention.d.ts +0 -26
  82. package/dist/tasks/convention.d.ts +0 -26
@@ -6,64 +6,66 @@ import { stat } from 'node:fs/promises';
6
6
  import { join } from 'node:path';
7
7
  import { setPublicDir } from '../assets/asset.js';
8
8
  import { listPublic, publicDeclarations } from '../assets/public.js';
9
- import { eventTypedFolders } from '../events/types.js';
10
9
  import { MissingForEvent } from '../events/registry.js';
11
10
  import { sourcesJoinVoice } from '../voice/usage.js';
12
11
  import { writeGenerated } from '../loader/generated.js';
13
- import { commandsConvention } from '../commands/convention.js';
12
+ import { commandDeclaration, scanCommands } from '../commands/declaration.js';
14
13
  import { CommandRouter } from '../commands/router.js';
15
14
  import { findConflicts } from '../commands/tree.js';
16
- import { componentsConvention, findDuplicates, } from '../components/convention.js';
15
+ import { componentDeclaration, findDuplicates, } from '../components/declaration.js';
17
16
  import { ComponentRouter } from '../components/router.js';
18
- import { tasksConvention } from '../tasks/convention.js';
19
- import { DEFAULT_PRESENCE, presenceConvention, } from '../presence/convention.js';
20
- import { assembleMessages, commandsDeclarations, languagesConvention, languageTypedFolders, messagesDeclarations, } from '../messages/convention.js';
17
+ import { taskDeclaration } from '../tasks/declaration.js';
18
+ import { DEFAULT_PRESENCE, presenceDeclaration, } from '../presence/declaration.js';
19
+ import { assembleMessages, commandsDeclarations, languageDeclaration, languageDefaultsDeclaration, messagesDeclarations, scanLanguages, } from '../messages/declaration.js';
21
20
  import { translation } from '../messages/translate.js';
22
21
  import { translateCommand, unknownCommands } from '../messages/commands.js';
23
22
  import { TimerScheduler, } from '../tasks/scheduler.js';
24
23
  import { createBot } from '../core/bot.js';
25
24
  import { watchMemory } from '../core/memory.js';
26
25
  import { GatewayIntent } from '../discord/intents.js';
27
- import { eventsConvention } from '../events/convention.js';
26
+ import { eventDeclaration } from '../events/declaration.js';
28
27
  import { EventRouter, intentsFor, intentsOf, limitsFor, } from '../events/router.js';
29
28
  import { GatewayFatalError, SessionLimitError } from '../gateway/errors.js';
30
- import { listFolder, loadBuilt, loadFolder, } from '../loader/loader.js';
29
+ import { loadSources, readDeclarations, siteName, } from '../loader/loader.js';
31
30
  import { locate, messageOf } from '../loader/locate.js';
32
31
  import { DiscordApiError, DiscordUnavailableError, InvalidTokenError, } from '../rest/errors.js';
33
32
  import { PreflightFailure } from './preflight.js';
34
33
  /**
35
- * Writes the types of a project (`.chapterjs/`): one project per typed
36
- * folder (every event, every language file), and what every file gets,
37
- * like the files of `public/`. With the commands and the languages as
38
- * they loaded, a language file only offers the ones without `description`
39
- * in their file, `t` is typed from the default language and `default:
40
- * true` is refused in every file when another one says it (`defaults`,
41
- * the files that do); without them (`sync`, which runs nothing),
42
- * every command is offered and the first language file types `t`.
34
+ * Writes the types of a project (`.chapterjs/`): what the project adds to
35
+ * 'chapterjs', like the files of `public/`, its languages and its
36
+ * commands. With the project as it loaded (`info`), a language only
37
+ * offers the commands without `description`, `t` is typed from the
38
+ * default language and `default: true` is refused in every language that
39
+ * says it when another one does; without it (`sync`, which runs nothing),
40
+ * the commands and the languages are found by reading the files, every
41
+ * command is offered, and the first language or the one that says
42
+ * `default: true` types `t`.
43
43
  * @returns how many files were written
44
44
  */
45
- export async function writeTypes(cwd, commands, messages, defaults = []) {
46
- const [files, languageFiles, commandFiles] = await Promise.all([
45
+ export async function writeTypes(cwd, info) {
46
+ const [files, found, names] = await Promise.all([
47
47
  listPublic(cwd),
48
- listFolder(cwd, languagesConvention),
49
- listFolder(cwd, commandsConvention),
48
+ info ? null : scanLanguages(cwd),
49
+ info ? null : scanCommands(cwd),
50
50
  ]);
51
- return writeGenerated(cwd, [...eventTypedFolders(), ...languageTypedFolders(languageFiles, defaults)], publicDeclarations(files) +
52
- messagesDeclarations(languageFiles, messages ? messages.files.get(messages.default) : undefined),
53
- // Only language files use it, and they are in the main project.
54
- commandsDeclarations(commandFiles.map(({ file }) => ({
55
- file,
56
- described: commands?.get(file)?.command.described ?? false,
57
- }))));
51
+ const languages = info ? info.languages : found.languages;
52
+ const defaultLocale = info ? info.defaultLocale : found.defaultLocale;
53
+ const commands = info
54
+ ? info.commands
55
+ : names.map(name => ({ name, described: false }));
56
+ return writeGenerated(cwd, publicDeclarations(files) +
57
+ messagesDeclarations(languages, defaultLocale) +
58
+ languageDefaultsDeclaration(info?.languageDefaults ?? []) +
59
+ commandsDeclarations(commands));
58
60
  }
59
- /** The conventional folders of a project: one per feature. */
60
- export const CONVENTIONS = [
61
- eventsConvention,
62
- commandsConvention,
63
- componentsConvention,
64
- tasksConvention,
65
- presenceConvention,
66
- languagesConvention,
61
+ /** The kinds of declarations a project is made of: one per feature. */
62
+ export const DECLARATIONS = [
63
+ eventDeclaration,
64
+ commandDeclaration,
65
+ componentDeclaration,
66
+ taskDeclaration,
67
+ presenceDeclaration,
68
+ languageDeclaration,
67
69
  ];
68
70
  /** Whether the folder is a project: it has a `src` folder. */
69
71
  export async function hasSources(cwd, log) {
@@ -79,13 +81,45 @@ const PRIVATE_INTENTS = GatewayIntent.DirectMessages |
79
81
  GatewayIntent.DirectMessageTyping |
80
82
  GatewayIntent.DirectMessagePolls;
81
83
  const megabytes = (bytes) => `${Math.round(bytes / 1024 / 1024)} MB`;
84
+ /** What tells one declaration from another: its file and its export. */
85
+ const keyOf = (site) => `${site.file}#${site.export}`;
86
+ /**
87
+ * Two declarations with the same name, where the name is the export's
88
+ * (a task): the later one is left out, with a message saying so.
89
+ */
90
+ function sameNames(entries, nameOf, what) {
91
+ const seen = new Map();
92
+ const conflicts = [];
93
+ const valid = [...entries]
94
+ .sort((a, b) => a.file === b.file
95
+ ? a.export < b.export
96
+ ? -1
97
+ : 1
98
+ : a.file < b.file
99
+ ? -1
100
+ : 1)
101
+ .filter(entry => {
102
+ const first = seen.get(nameOf(entry));
103
+ if (!first) {
104
+ seen.set(nameOf(entry), entry);
105
+ return true;
106
+ }
107
+ conflicts.push({
108
+ file: entry.file,
109
+ export: entry.export,
110
+ message: `There is already a ${what} named ${nameOf(entry)}, in ${first.file}: the name of the export is the name of the ${what}, so rename one of them.`,
111
+ });
112
+ return false;
113
+ });
114
+ return { valid, conflicts };
115
+ }
82
116
  export function createProject(options) {
83
117
  const { cwd, log } = options;
84
118
  // Where `asset()` reads the files of the project from.
85
119
  setPublicDir(cwd);
86
- const report = ({ file, error }) => {
87
- const where = locate(error, cwd);
88
- log.error(`${where ? `${where.file}:${where.line}` : file} ${messageOf(error)}`);
120
+ const report = (failure) => {
121
+ const where = locate(failure.error, cwd);
122
+ log.error(`${where ? `${where.file}:${where.line}` : siteName(failure)} ${messageOf(failure.error)}`);
89
123
  };
90
124
  const router = new EventRouter((file, error) => report({ file, error }), (event, error) => {
91
125
  const missing = error instanceof MissingForEvent ? error : null;
@@ -109,11 +143,13 @@ export function createProject(options) {
109
143
  let components = new Map();
110
144
  let tasks = new Map();
111
145
  let presence = null;
146
+ /** The files the presence was declared in, to keep it when they break. */
147
+ let presenceFiles = new Set();
112
148
  // Joining voice needs the bot's own voice state, sent with an intent.
113
149
  let voice = options.joinsVoice ?? false;
114
150
  const neededIntents = () => intentsFor(events.values()) | (voice ? GatewayIntent.GuildVoiceStates : 0);
115
151
  let messages = null;
116
- /** The last version of each language file that loaded. */
152
+ /** The last version of each language that loaded. */
117
153
  let languages = new Map();
118
154
  let languageDefaults = [];
119
155
  /** The bot running the files now, to give it what a reload changes. */
@@ -141,107 +177,134 @@ export function createProject(options) {
141
177
  get messages() {
142
178
  return messages;
143
179
  },
144
- get languageDefaults() {
145
- return languageDefaults;
146
- },
180
+ typesInfo: () => ({
181
+ languages: [...languages.values()].map(entry => ({
182
+ file: entry.file,
183
+ export: entry.export,
184
+ name: entry.name,
185
+ locale: entry.language.locale,
186
+ })),
187
+ ...(messages ? { defaultLocale: messages.default } : {}),
188
+ languageDefaults,
189
+ commands: [...commands.values()].map(({ command }) => ({
190
+ name: command.path.join(' '),
191
+ described: command.described,
192
+ })),
193
+ }),
147
194
  report,
148
195
  async load() {
149
196
  const { built } = options;
150
197
  if (options.joinsVoice === undefined) {
151
198
  voice = await sourcesJoinVoice(cwd);
152
199
  }
153
- const [eventFiles, commandFiles, componentFiles, taskFiles, presenceFiles, messageFiles,] = await Promise.all(built
154
- ? [
155
- loadBuilt(eventsConvention, built),
156
- loadBuilt(commandsConvention, built),
157
- loadBuilt(componentsConvention, built),
158
- loadBuilt(tasksConvention, built),
159
- loadBuilt(presenceConvention, built),
160
- loadBuilt(languagesConvention, built),
161
- ]
162
- : [
163
- loadFolder(cwd, eventsConvention),
164
- loadFolder(cwd, commandsConvention),
165
- loadFolder(cwd, componentsConvention),
166
- loadFolder(cwd, tasksConvention),
167
- loadFolder(cwd, presenceConvention),
168
- loadFolder(cwd, languagesConvention),
169
- ]);
170
- const nextEvents = new Map();
171
- for (const { file, value } of eventFiles.loaded) {
172
- nextEvents.set(file, { file, event: value });
173
- }
174
- for (const { file } of eventFiles.failed) {
175
- const previous = events.get(file);
176
- if (previous)
177
- nextEvents.set(file, previous);
178
- }
179
- events = nextEvents;
200
+ // Every file of src/ runs (or ran, in a build); what each exports is
201
+ // then read, kind by kind.
202
+ const sources = built ? { modules: built, failed: [] } : await loadSources(cwd);
203
+ const read = (declaration) => readDeclarations(sources.modules, declaration);
204
+ const [eventItems, commandItems, componentItems, taskItems, presenceItems, languageItems,] = [
205
+ read(eventDeclaration),
206
+ read(commandDeclaration),
207
+ read(componentDeclaration),
208
+ read(taskDeclaration),
209
+ read(presenceDeclaration),
210
+ read(languageDeclaration),
211
+ ];
212
+ const broken = new Set(sources.failed.map(({ file }) => file));
213
+ /**
214
+ * The next version of a kind: what loaded now, plus the last good
215
+ * version of what failed (an export that can't be read, or a file
216
+ * that can't run at all).
217
+ */
218
+ const next = (previous, items, make) => {
219
+ const result = new Map();
220
+ for (const item of items.loaded)
221
+ result.set(keyOf(item), make(item));
222
+ const failedKeys = new Set(items.failed.map(({ file, export: exported }) => keyOf({ file, export: exported ?? 'default' })));
223
+ for (const [key, entry] of previous) {
224
+ if (failedKeys.has(key) || broken.has(entry.file)) {
225
+ result.set(key, entry);
226
+ }
227
+ }
228
+ return result;
229
+ };
230
+ events = next(events, eventItems, item => ({
231
+ file: item.file,
232
+ export: item.export,
233
+ event: item.value,
234
+ }));
180
235
  router.set(events.values());
181
- const entries = commandFiles.loaded.map(({ file, value }) => ({ file, command: value }));
182
- for (const { file } of commandFiles.failed) {
183
- const previous = commands.get(file);
184
- if (previous)
185
- entries.push(previous);
186
- }
187
- const { valid, conflicts } = findConflicts(entries);
188
- commands = new Map(valid.map(entry => [entry.file, entry]));
189
- const pieces = componentFiles.loaded.map(({ file, value }) => ({ file, component: value }));
190
- for (const { file } of componentFiles.failed) {
191
- const previous = components.get(file);
192
- if (previous)
193
- pieces.push(previous);
194
- }
195
- const unique = findDuplicates(pieces);
196
- components = new Map(unique.valid.map(entry => [entry.file, entry]));
236
+ const { valid, conflicts } = findConflicts([
237
+ ...next(commands, commandItems, item => ({
238
+ file: item.file,
239
+ export: item.export,
240
+ command: item.value,
241
+ })).values(),
242
+ ]);
243
+ commands = new Map(valid.map(entry => [keyOf(entry), entry]));
244
+ const unique = findDuplicates([
245
+ ...next(components, componentItems, item => ({
246
+ file: item.file,
247
+ export: item.export,
248
+ component: item.value,
249
+ })).values(),
250
+ ]);
251
+ components = new Map(unique.valid.map(entry => [keyOf(entry), entry]));
197
252
  componentRouter.set(components.values());
198
- const nextTasks = new Map();
199
- for (const { file, value } of taskFiles.loaded) {
200
- nextTasks.set(file, { file, task: value });
201
- }
202
- for (const { file } of taskFiles.failed) {
203
- const previous = tasks.get(file);
204
- if (previous)
205
- nextTasks.set(file, previous);
206
- }
207
- tasks = nextTasks;
253
+ const sameTask = sameNames([
254
+ ...next(tasks, taskItems, item => ({
255
+ file: item.file,
256
+ export: item.export,
257
+ task: item.value,
258
+ })).values(),
259
+ ], entry => entry.task.name, 'task');
260
+ tasks = new Map(sameTask.valid.map(entry => [keyOf(entry), entry]));
208
261
  scheduler.set(tasks.values());
209
- // One presence file. A second one (another extension) is left out.
210
- const [first, ...extra] = presenceFiles.loaded;
262
+ // One presence. A second one is left out.
263
+ const [first, ...extra] = presenceItems.loaded;
211
264
  if (first)
212
265
  presence = first.value;
213
- else if (presenceFiles.failed.length === 0)
266
+ else if (presenceItems.failed.length === 0 &&
267
+ ![...broken].some(file => presenceFiles.has(file))) {
214
268
  presence = null;
215
- const twice = extra.map(({ file }) => ({
216
- file,
217
- error: new TypeError(`There are two presence files: ${first.file} is used, keep only one.`),
218
- }));
219
- const nextLanguages = new Map();
220
- for (const { file, value } of messageFiles.loaded) {
221
- nextLanguages.set(file, { file, language: value });
222
- }
223
- for (const { file } of messageFiles.failed) {
224
- const previous = languages.get(file);
225
- if (previous)
226
- nextLanguages.set(file, previous);
227
269
  }
228
- // What the files say now, before a file is put back to its last good
229
- // version: two files saying default: true are both underlined.
270
+ presenceFiles = new Set(presenceItems.loaded.map(({ file }) => file));
271
+ const twice = extra.map(item => ({
272
+ file: item.file,
273
+ export: item.export,
274
+ error: new TypeError(`There are two presences: the one of ${siteName(first)} is used, keep only one.`),
275
+ }));
276
+ const nextLanguages = next(languages, languageItems, item => ({
277
+ file: item.file,
278
+ export: item.export,
279
+ name: item.name,
280
+ language: item.value,
281
+ }));
282
+ // What the files say now, before a declaration is put back to its
283
+ // last good version: two languages saying default: true are both
284
+ // underlined.
230
285
  languageDefaults = [...nextLanguages.values()]
231
286
  .filter(({ language }) => language.isDefault)
232
- .map(({ file }) => file);
287
+ .map(({ language }) => language.locale);
233
288
  const previousLanguages = languages;
234
289
  languages = nextLanguages;
235
290
  let assembled = assembleMessages([...languages.values()]);
236
291
  // A language that no longer matches the others keeps its last version
237
- // that did, like a file that no longer loads.
238
- const restored = assembled.failed.filter(({ file }) => {
239
- const previous = previousLanguages.get(file);
240
- return previous !== undefined && previous !== languages.get(file);
292
+ // that did, like a declaration that no longer loads.
293
+ const restored = assembled.failed.filter(failure => {
294
+ const key = keyOf({
295
+ file: failure.file,
296
+ export: failure.export ?? 'default',
297
+ });
298
+ const previous = previousLanguages.get(key);
299
+ return previous !== undefined && previous !== languages.get(key);
241
300
  });
242
301
  if (restored.length > 0) {
243
- for (const { file } of restored) {
244
- languages.set(file, previousLanguages.get(file));
302
+ for (const failure of restored) {
303
+ const key = keyOf({
304
+ file: failure.file,
305
+ export: failure.export ?? 'default',
306
+ });
307
+ languages.set(key, previousLanguages.get(key));
245
308
  }
246
309
  const again = assembleMessages([...languages.values()]);
247
310
  assembled = {
@@ -260,34 +323,36 @@ export function createProject(options) {
260
323
  // be described is left out.
261
324
  const badTexts = [...assembled.failed];
262
325
  if (messages) {
263
- const paths = new Set([...commands.values()].map(entry => entry.command.path.join('/')));
264
- for (const { file, path } of unknownCommands(messages, paths)) {
326
+ const names = new Set([...commands.values()].map(entry => entry.command.path.join(' ')));
327
+ for (const { file, path } of unknownCommands(messages, names)) {
265
328
  badTexts.push({
266
329
  file,
267
- error: new TypeError(`"commands" translates "${path}", which is not a command of this project${paths.size > 0 ? ` (its commands are: ${[...paths].join(', ')})` : ''}. The key is the path of the command file, like 'ping' or 'mod/ban'.`),
330
+ error: new TypeError(`"commands" translates "${path}", which is not a command of this project${names.size > 0 ? ` (its commands are: ${[...names].join(', ')})` : ''}. The key is the name of the command, like 'ping' or 'mod ban'.`),
268
331
  });
269
332
  }
270
333
  }
271
- for (const [file, entry] of commands) {
272
- const { command, failed } = translateCommand(file, entry.command, messages);
334
+ for (const [key, entry] of commands) {
335
+ const { command, failed } = translateCommand(siteName(entry), entry.command, messages);
273
336
  badTexts.push(...failed);
274
337
  if (command)
275
- commands.set(file, { ...entry, command });
338
+ commands.set(key, { ...entry, command });
276
339
  else
277
- commands.delete(file);
340
+ commands.delete(key);
278
341
  }
279
342
  commandRouter.set(commands.values());
280
343
  return [
281
- ...eventFiles.failed,
282
- ...commandFiles.failed,
283
- ...componentFiles.failed,
284
- ...taskFiles.failed,
285
- ...presenceFiles.failed,
286
- ...messageFiles.failed,
344
+ ...sources.failed,
345
+ ...eventItems.failed,
346
+ ...commandItems.failed,
347
+ ...componentItems.failed,
348
+ ...taskItems.failed,
349
+ ...presenceItems.failed,
350
+ ...languageItems.failed,
287
351
  ...badTexts,
288
352
  ...twice,
289
- ...[...conflicts, ...unique.conflicts].map(({ file, message }) => ({
353
+ ...[...conflicts, ...unique.conflicts, ...sameTask.conflicts].map(({ file, export: exported, message }) => ({
290
354
  file,
355
+ export: exported,
291
356
  error: new TypeError(message),
292
357
  })),
293
358
  ];
@@ -299,7 +364,7 @@ export function createProject(options) {
299
364
  tasks.size === 0 &&
300
365
  presence === null &&
301
366
  messages === null) {
302
- return 'Nothing to run yet: add a file in src/commands/ or in a folder like src/events/messageCreate/';
367
+ return "Nothing to run yet: export a command or an event from a file of src/, like export default command({ name: 'ping', ... })";
303
368
  }
304
369
  return `${[
305
370
  ...(commands.size > 0
@@ -0,0 +1,42 @@
1
+ export declare const CHANGELOG_URL = "https://www.chapterjs.org/changelog";
2
+ export interface UpdateCheckOptions {
3
+ /** The folder of the project. */
4
+ cwd: string;
5
+ /** The version of the framework that runs. */
6
+ version: string;
7
+ env: Record<string, string | undefined>;
8
+ /** Only tests change these. */
9
+ registryUrl?: string;
10
+ timeout?: number;
11
+ now?: () => number;
12
+ }
13
+ /** A version newer than the one that runs. */
14
+ export interface Update {
15
+ current: string;
16
+ latest: string;
17
+ /** What to type to install it, with the package manager of the project. */
18
+ command: string;
19
+ }
20
+ /**
21
+ * The command that updates `chapterjs` in a project: with the package
22
+ * manager its lockfile belongs to, else the one that started the command
23
+ * (`pnpm dev` sets `npm_config_user_agent`), else npm, which every
24
+ * machine with Node has.
25
+ */
26
+ export declare function updateCommand(cwd: string, env: Record<string, string | undefined>): string;
27
+ /**
28
+ * Whether `latest` is a newer version than `current`. Only the three
29
+ * numbers count: `1.0.0-beta.2` and `1.0.0` are not told apart, so a
30
+ * prerelease never says to install the final version it precedes.
31
+ */
32
+ export declare function isNewer(latest: string, current: string): boolean;
33
+ /**
34
+ * Whether a newer `chapterjs` is published. The registry is asked at most
35
+ * once a day (`.chapterjs/cache/update.json` keeps the answer), with a
36
+ * short timeout, and never throws: without network, or when the registry
37
+ * does not answer, there is nothing to say. Resolves with the update to
38
+ * install, or `null` when the bot runs the latest version.
39
+ */
40
+ export declare function checkForUpdate(options: UpdateCheckOptions): Promise<Update | null>;
41
+ /** The line that tells the developer about an update. */
42
+ export declare function describeUpdate(update: Update): string;
@@ -0,0 +1,125 @@
1
+ // Whether a newer chapterjs is published, and the command that installs it:
2
+ // told where the developer starts their bot, so nobody has to watch npm.
3
+ import { existsSync } from 'node:fs';
4
+ import { mkdir, readFile, writeFile } from 'node:fs/promises';
5
+ import { dirname, join } from 'node:path';
6
+ /** Where the package is published. */
7
+ const NPM_REGISTRY = 'https://registry.npmjs.org';
8
+ /** Asked once a day: a version is not published more often. */
9
+ const CHECK_EVERY = 24 * 60 * 60 * 1000;
10
+ /** A registry that does not answer must never hold the bot. */
11
+ const DEFAULT_TIMEOUT = 5000;
12
+ export const CHANGELOG_URL = 'https://www.chapterjs.org/changelog';
13
+ const PACKAGE_MANAGERS = ['pnpm', 'npm', 'yarn', 'bun'];
14
+ /** The lockfiles each package manager writes next to `package.json`. */
15
+ const LOCKFILES = {
16
+ pnpm: ['pnpm-lock.yaml'],
17
+ npm: ['package-lock.json', 'npm-shrinkwrap.json'],
18
+ yarn: ['yarn.lock'],
19
+ bun: ['bun.lock', 'bun.lockb'],
20
+ };
21
+ /** What installs the latest version with each package manager. */
22
+ const COMMANDS = {
23
+ pnpm: 'pnpm update chapterjs --latest',
24
+ npm: 'npm install chapterjs@latest',
25
+ yarn: 'yarn add chapterjs@latest',
26
+ bun: 'bun add chapterjs@latest',
27
+ };
28
+ /**
29
+ * The command that updates `chapterjs` in a project: with the package
30
+ * manager its lockfile belongs to, else the one that started the command
31
+ * (`pnpm dev` sets `npm_config_user_agent`), else npm, which every
32
+ * machine with Node has.
33
+ */
34
+ export function updateCommand(cwd, env) {
35
+ const fromLockfile = PACKAGE_MANAGERS.find(pm => LOCKFILES[pm].some(name => existsSync(join(cwd, name))));
36
+ const agent = env.npm_config_user_agent?.split('/')[0];
37
+ const fromAgent = PACKAGE_MANAGERS.find(pm => pm === agent);
38
+ return COMMANDS[fromLockfile ?? fromAgent ?? 'npm'];
39
+ }
40
+ /** `1.2.3`, with or without a prerelease suffix, as numbers. */
41
+ function parseVersion(version) {
42
+ const match = /^v?(\d+)\.(\d+)\.(\d+)(?:[-+].*)?$/.exec(version.trim());
43
+ return match ? [Number(match[1]), Number(match[2]), Number(match[3])] : null;
44
+ }
45
+ /**
46
+ * Whether `latest` is a newer version than `current`. Only the three
47
+ * numbers count: `1.0.0-beta.2` and `1.0.0` are not told apart, so a
48
+ * prerelease never says to install the final version it precedes.
49
+ */
50
+ export function isNewer(latest, current) {
51
+ const a = parseVersion(latest);
52
+ const b = parseVersion(current);
53
+ if (!a || !b)
54
+ return false;
55
+ for (let i = 0; i < 3; i++) {
56
+ if (a[i] !== b[i])
57
+ return a[i] > b[i];
58
+ }
59
+ return false;
60
+ }
61
+ /** The version the registry publishes as `latest`, or `null`. */
62
+ async function fetchLatest(registryUrl, timeout) {
63
+ try {
64
+ const response = await fetch(`${registryUrl}/chapterjs/latest`, {
65
+ headers: { accept: 'application/json' },
66
+ signal: AbortSignal.timeout(timeout),
67
+ });
68
+ if (!response.ok)
69
+ return null;
70
+ const { version } = (await response.json());
71
+ return typeof version === 'string' && parseVersion(version)
72
+ ? version
73
+ : null;
74
+ }
75
+ catch {
76
+ return null;
77
+ }
78
+ }
79
+ /**
80
+ * Whether a newer `chapterjs` is published. The registry is asked at most
81
+ * once a day (`.chapterjs/cache/update.json` keeps the answer), with a
82
+ * short timeout, and never throws: without network, or when the registry
83
+ * does not answer, there is nothing to say. Resolves with the update to
84
+ * install, or `null` when the bot runs the latest version.
85
+ */
86
+ export async function checkForUpdate(options) {
87
+ const { cwd, version, env } = options;
88
+ const now = options.now ?? Date.now;
89
+ const file = join(cwd, '.chapterjs', 'cache', 'update.json');
90
+ const cached = await readFile(file, 'utf8')
91
+ .then((text) => {
92
+ const value = JSON.parse(text);
93
+ return value &&
94
+ typeof value.checked === 'number' &&
95
+ typeof value.latest === 'string'
96
+ ? { checked: value.checked, latest: value.latest }
97
+ : null;
98
+ })
99
+ .catch(() => null);
100
+ let latest;
101
+ if (cached && now() - cached.checked < CHECK_EVERY) {
102
+ latest = cached.latest;
103
+ }
104
+ else {
105
+ latest = await fetchLatest(options.registryUrl ?? env.CHAPTERJS_REGISTRY_URL ?? NPM_REGISTRY, options.timeout ?? DEFAULT_TIMEOUT);
106
+ if (latest) {
107
+ // A folder or a disk that can't be written changes nothing: the
108
+ // registry is asked again next time.
109
+ await mkdir(dirname(file), { recursive: true })
110
+ .then(() => writeFile(file, JSON.stringify({ checked: now(), latest })))
111
+ .catch(() => { });
112
+ }
113
+ else {
114
+ // Yesterday's answer is better than none.
115
+ latest = cached?.latest ?? null;
116
+ }
117
+ }
118
+ if (!latest || !isNewer(latest, version))
119
+ return null;
120
+ return { current: version, latest, command: updateCommand(cwd, env) };
121
+ }
122
+ /** The line that tells the developer about an update. */
123
+ export function describeUpdate(update) {
124
+ return `A new version of chapterjs is out: ${update.latest} (you have ${update.current}). To update, run:\n${update.command}\nWhat changed: ${CHANGELOG_URL}`;
125
+ }