@aspalagin/openclaw-max 0.7.2

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 (79) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +424 -0
  3. package/SKILL.md +108 -0
  4. package/dist/index.js +17 -0
  5. package/dist/setup-entry.js +8 -0
  6. package/dist/src/accounts.js +148 -0
  7. package/dist/src/actions.js +421 -0
  8. package/dist/src/api.js +553 -0
  9. package/dist/src/callbacks.js +111 -0
  10. package/dist/src/channel-agent-prompt.js +38 -0
  11. package/dist/src/channel-config.js +103 -0
  12. package/dist/src/channel-directory.js +84 -0
  13. package/dist/src/channel-lifecycle.js +219 -0
  14. package/dist/src/channel-outbound.js +133 -0
  15. package/dist/src/channel-policy.js +130 -0
  16. package/dist/src/channel.js +76 -0
  17. package/dist/src/config-schema.js +133 -0
  18. package/dist/src/deliver.js +126 -0
  19. package/dist/src/dispatch.js +186 -0
  20. package/dist/src/format.js +31 -0
  21. package/dist/src/inbound-attachments.js +125 -0
  22. package/dist/src/inbound.js +362 -0
  23. package/dist/src/media-temp.js +92 -0
  24. package/dist/src/model-buttons.js +96 -0
  25. package/dist/src/monitor-types.js +27 -0
  26. package/dist/src/monitor.js +40 -0
  27. package/dist/src/onboarding.js +180 -0
  28. package/dist/src/polling.js +98 -0
  29. package/dist/src/presentation.js +374 -0
  30. package/dist/src/runtime.js +36 -0
  31. package/dist/src/russian-trusted-ca.js +84 -0
  32. package/dist/src/send.js +612 -0
  33. package/dist/src/state.js +111 -0
  34. package/dist/src/sticker-cache.js +26 -0
  35. package/dist/src/stream-draft.js +102 -0
  36. package/dist/src/types.js +5 -0
  37. package/dist/src/webhook-queue.js +215 -0
  38. package/dist/src/webhook-runner.js +233 -0
  39. package/dist/src/webhook.js +196 -0
  40. package/openclaw.plugin.json +263 -0
  41. package/package.json +100 -0
  42. package/scripts/check-cycles.mjs +38 -0
  43. package/scripts/test-api.mjs +169 -0
  44. package/scripts/test-send.mjs +162 -0
  45. package/scripts/update-schema.mjs +48 -0
  46. package/src/accounts.ts +193 -0
  47. package/src/actions.ts +501 -0
  48. package/src/api.ts +758 -0
  49. package/src/callbacks.ts +140 -0
  50. package/src/channel-agent-prompt.ts +47 -0
  51. package/src/channel-config.ts +138 -0
  52. package/src/channel-directory.ts +90 -0
  53. package/src/channel-lifecycle.ts +256 -0
  54. package/src/channel-outbound.ts +159 -0
  55. package/src/channel-policy.ts +185 -0
  56. package/src/channel.ts +99 -0
  57. package/src/config-schema.ts +154 -0
  58. package/src/deliver.ts +168 -0
  59. package/src/dispatch.ts +224 -0
  60. package/src/format.ts +33 -0
  61. package/src/inbound-attachments.ts +152 -0
  62. package/src/inbound.ts +412 -0
  63. package/src/media-temp.ts +103 -0
  64. package/src/model-buttons.ts +150 -0
  65. package/src/monitor-types.ts +61 -0
  66. package/src/monitor.ts +49 -0
  67. package/src/onboarding.ts +215 -0
  68. package/src/polling.ts +125 -0
  69. package/src/presentation.ts +452 -0
  70. package/src/runtime.ts +56 -0
  71. package/src/russian-trusted-ca.ts +86 -0
  72. package/src/send.ts +774 -0
  73. package/src/state.ts +149 -0
  74. package/src/sticker-cache.ts +28 -0
  75. package/src/stream-draft.ts +125 -0
  76. package/src/types.ts +263 -0
  77. package/src/webhook-queue.ts +248 -0
  78. package/src/webhook-runner.ts +296 -0
  79. package/src/webhook.ts +268 -0
@@ -0,0 +1,196 @@
1
+ /**
2
+ * MAX webhook transport — HTTP ingress on the gateway server and the
3
+ * subscription lifecycle (GET/POST/DELETE /subscriptions).
4
+ *
5
+ * The route is registered dynamically from the account lifecycle through
6
+ * `registerPluginHttpRoute` (plugin-sdk/webhook-ingress): it lives exactly as
7
+ * long as the account task, carries plugin-managed auth (the MAX shared
8
+ * secret), and a failed registration fails the account start instead of
9
+ * reporting ready without live ingress.
10
+ */
11
+ import { timingSafeEqual } from 'node:crypto';
12
+ import { normalizeWebhookPath, readJsonWebhookBodyOrReject, registerPluginHttpRoute, resolveWebhookPath, } from 'openclaw/plugin-sdk/webhook-ingress';
13
+ /** Plugin id from openclaw.plugin.json — the owner of the gateway route. */
14
+ export const MAX_PLUGIN_ID = 'openclaw-max';
15
+ /** Stable same-plugin route sub-owner (see gateway-routes docs). */
16
+ export const MAX_WEBHOOK_ROUTE_SOURCE = 'max-webhook';
17
+ export const DEFAULT_MAX_WEBHOOK_PATH = '/max/webhook';
18
+ /** Header MAX sends with the subscription secret. */
19
+ const SECRET_HEADER = 'x-max-bot-api-secret';
20
+ const DEDUPE_CAPACITY = 1000;
21
+ function secretsMatch(expected, provided) {
22
+ const a = Buffer.from(expected);
23
+ const b = Buffer.from(provided);
24
+ if (a.length !== b.length)
25
+ return false;
26
+ return timingSafeEqual(a, b);
27
+ }
28
+ const webhookTargets = new Map();
29
+ export function resolveMaxWebhookPath(webhookPath, webhookUrl) {
30
+ return (resolveWebhookPath({ webhookPath, webhookUrl, defaultPath: DEFAULT_MAX_WEBHOOK_PATH }) ??
31
+ DEFAULT_MAX_WEBHOOK_PATH);
32
+ }
33
+ export function registerMaxWebhookTarget(target) {
34
+ const key = normalizeWebhookPath(target.path);
35
+ const normalizedTarget = { ...target, path: key, seen: new Map() };
36
+ webhookTargets.set(key, [...(webhookTargets.get(key) ?? []), normalizedTarget]);
37
+ let active = true;
38
+ return () => {
39
+ if (!active)
40
+ return;
41
+ active = false;
42
+ const updated = (webhookTargets.get(key) ?? []).filter((entry) => entry !== normalizedTarget);
43
+ if (updated.length > 0) {
44
+ webhookTargets.set(key, updated);
45
+ }
46
+ else {
47
+ webhookTargets.delete(key);
48
+ }
49
+ };
50
+ }
51
+ /**
52
+ * Register the gateway HTTP route for a webhook path. Canonically equal paths
53
+ * of several accounts share one route: the handler picks the target by secret.
54
+ */
55
+ export function registerMaxWebhookRoute(params) {
56
+ const register = params.register ?? registerPluginHttpRoute;
57
+ return register({
58
+ path: normalizeWebhookPath(params.path),
59
+ auth: 'plugin',
60
+ match: 'exact',
61
+ pluginId: MAX_PLUGIN_ID,
62
+ source: MAX_WEBHOOK_ROUTE_SOURCE,
63
+ accountId: params.accountId,
64
+ replaceExisting: true,
65
+ throwOnFailure: true,
66
+ log: params.log,
67
+ handler: handleMaxWebhookRequest,
68
+ });
69
+ }
70
+ /**
71
+ * Key for redelivery dedupe: MAX retries a delivery that was not acknowledged
72
+ * with 200 in time, so the same update can arrive twice.
73
+ * @internal exported for testing.
74
+ */
75
+ export function maxUpdateDedupeKey(update) {
76
+ const id = update.callback?.callback_id ??
77
+ update.message?.body?.mid ??
78
+ (typeof update.message_id === 'string' ? update.message_id : undefined);
79
+ if (!id && update.timestamp == null)
80
+ return undefined;
81
+ return `${update.update_type}:${update.timestamp ?? ''}:${id ?? ''}`;
82
+ }
83
+ /** Returns true when the key was already seen (LRU bounded by DEDUPE_CAPACITY). */
84
+ function rememberUpdate(seen, key) {
85
+ if (seen.has(key)) {
86
+ seen.delete(key);
87
+ seen.set(key, true);
88
+ return true;
89
+ }
90
+ seen.set(key, true);
91
+ if (seen.size > DEDUPE_CAPACITY) {
92
+ const oldest = seen.keys().next().value;
93
+ if (oldest !== undefined)
94
+ seen.delete(oldest);
95
+ }
96
+ return false;
97
+ }
98
+ export async function handleMaxWebhookRequest(req, res) {
99
+ const url = new URL(req.url ?? '/', 'http://localhost');
100
+ const path = normalizeWebhookPath(url.pathname);
101
+ const targets = webhookTargets.get(path);
102
+ if (!targets || targets.length === 0) {
103
+ return false;
104
+ }
105
+ if (req.method !== 'POST') {
106
+ res.statusCode = 405;
107
+ res.setHeader('Allow', 'POST');
108
+ res.end('Method Not Allowed');
109
+ return true;
110
+ }
111
+ // Authenticate before reading the body. Targets without a secret never
112
+ // match: an unauthenticated webhook would let anyone inject updates.
113
+ const rawSecret = req.headers[SECRET_HEADER];
114
+ const providedSecret = Array.isArray(rawSecret) ? (rawSecret[0] ?? '') : (rawSecret ?? '');
115
+ const matchedTarget = providedSecret
116
+ ? targets.find((target) => target.secret && secretsMatch(target.secret, providedSecret))
117
+ : undefined;
118
+ if (!matchedTarget) {
119
+ res.statusCode = 401;
120
+ res.end('Unauthorized');
121
+ return true;
122
+ }
123
+ // Size/timeout/closed-connection/malformed-JSON failures are answered by the
124
+ // SDK helper (413/408/400) — it has already written the response.
125
+ const body = await readJsonWebhookBodyOrReject({
126
+ req,
127
+ res,
128
+ maxBytes: 1024 * 1024,
129
+ timeoutMs: 30_000,
130
+ emptyObjectOnEmpty: false,
131
+ invalidJsonMessage: 'invalid payload',
132
+ });
133
+ if (!body.ok) {
134
+ return true;
135
+ }
136
+ const raw = body.value;
137
+ if (!raw ||
138
+ typeof raw !== 'object' ||
139
+ Array.isArray(raw) ||
140
+ typeof raw.update_type !== 'string') {
141
+ res.statusCode = 400;
142
+ res.end('invalid payload');
143
+ return true;
144
+ }
145
+ const update = raw;
146
+ // MAX needs 200 within 30 s while an agent turn can take minutes: ack first,
147
+ // then hand the update to the account task. Errors are logged, never turned
148
+ // into a non-200 (that would only make MAX redeliver and eventually
149
+ // unsubscribe the bot). onUpdate must only enqueue: this request's work
150
+ // admission is released when the handler returns, so agent/session work
151
+ // started from here would fail with GatewayDrainingError.
152
+ res.statusCode = 200;
153
+ res.setHeader('Content-Type', 'application/json');
154
+ res.end(JSON.stringify({ ok: true }));
155
+ const dedupeKey = maxUpdateDedupeKey(update);
156
+ if (dedupeKey && rememberUpdate(matchedTarget.seen, dedupeKey)) {
157
+ matchedTarget.log?.(`[${matchedTarget.account.accountId}] MAX webhook: duplicate ${dedupeKey} skipped`);
158
+ return true;
159
+ }
160
+ void Promise.resolve()
161
+ .then(() => matchedTarget.onUpdate(update))
162
+ .catch((err) => {
163
+ matchedTarget.error?.(`[${matchedTarget.account.accountId}] Webhook update processing failed: ${String(err)}`);
164
+ });
165
+ return true;
166
+ }
167
+ /**
168
+ * Subscribe to MAX webhook
169
+ */
170
+ export async function subscribeMaxWebhook(params) {
171
+ const { api, webhookUrl, secret, updateTypes } = params;
172
+ await api.subscribe({
173
+ url: webhookUrl,
174
+ update_types: updateTypes ?? [
175
+ 'message_created',
176
+ 'message_callback',
177
+ 'message_edited',
178
+ 'message_removed',
179
+ 'bot_started',
180
+ 'bot_stopped',
181
+ 'bot_added',
182
+ 'bot_removed',
183
+ 'dialog_cleared',
184
+ 'dialog_removed',
185
+ 'chat_title_changed',
186
+ ],
187
+ secret,
188
+ });
189
+ }
190
+ /**
191
+ * Unsubscribe from MAX webhook
192
+ */
193
+ export async function unsubscribeMaxWebhook(params) {
194
+ const { api, webhookUrl } = params;
195
+ await api.unsubscribe(webhookUrl);
196
+ }
@@ -0,0 +1,263 @@
1
+ {
2
+ "id": "openclaw-max",
3
+ "name": "MAX",
4
+ "description": "MAX messenger channel plugin (max.ru Bot API via platform-api2.max.ru, long polling or webhook)",
5
+ "version": "0.7.2",
6
+ "channels": [
7
+ "max"
8
+ ],
9
+ "channelEnvVars": {
10
+ "max": [
11
+ "MAX_BOT_TOKEN"
12
+ ]
13
+ },
14
+ "channelConfigs": {
15
+ "max": {
16
+ "schema": {
17
+ "$schema": "http://json-schema.org/draft-07/schema#",
18
+ "type": "object",
19
+ "definitions": {
20
+ "allowList": {
21
+ "type": "array",
22
+ "items": {
23
+ "anyOf": [
24
+ {
25
+ "type": "string"
26
+ },
27
+ {
28
+ "type": "number"
29
+ }
30
+ ]
31
+ }
32
+ },
33
+ "groupConfig": {
34
+ "type": "object",
35
+ "properties": {
36
+ "requireMention": {
37
+ "type": "boolean"
38
+ },
39
+ "tools": {
40
+ "type": "object"
41
+ },
42
+ "skills": {
43
+ "type": "array",
44
+ "items": {
45
+ "type": "string"
46
+ }
47
+ },
48
+ "enabled": {
49
+ "type": "boolean"
50
+ },
51
+ "allowFrom": {
52
+ "$ref": "#/definitions/allowList"
53
+ },
54
+ "systemPrompt": {
55
+ "type": "string"
56
+ }
57
+ },
58
+ "additionalProperties": false
59
+ },
60
+ "accountConfig": {
61
+ "type": "object",
62
+ "properties": {
63
+ "name": {
64
+ "type": "string"
65
+ },
66
+ "enabled": {
67
+ "type": "boolean"
68
+ },
69
+ "markdown": {
70
+ "type": "object"
71
+ },
72
+ "botToken": {
73
+ "type": "string"
74
+ },
75
+ "tokenFile": {
76
+ "type": "string"
77
+ },
78
+ "dmPolicy": {
79
+ "type": "string",
80
+ "enum": [
81
+ "pairing",
82
+ "allowlist",
83
+ "open",
84
+ "disabled"
85
+ ],
86
+ "default": "pairing"
87
+ },
88
+ "allowFrom": {
89
+ "$ref": "#/definitions/allowList"
90
+ },
91
+ "groupAllowFrom": {
92
+ "$ref": "#/definitions/allowList"
93
+ },
94
+ "groupPolicy": {
95
+ "type": "string",
96
+ "enum": [
97
+ "allowlist",
98
+ "open",
99
+ "disabled"
100
+ ],
101
+ "default": "allowlist"
102
+ },
103
+ "groups": {
104
+ "type": "object",
105
+ "additionalProperties": {
106
+ "$ref": "#/definitions/groupConfig"
107
+ }
108
+ },
109
+ "transport": {
110
+ "type": "string",
111
+ "enum": [
112
+ "polling",
113
+ "webhook"
114
+ ],
115
+ "description": "Update transport. Default: webhook when webhookUrl is set, otherwise polling. MAX does not serve long polling while a webhook subscription exists."
116
+ },
117
+ "webhookUrl": {
118
+ "type": "string",
119
+ "description": "Public HTTPS URL (port 443, trusted CA) MAX posts updates to; its path is served on the gateway HTTP server."
120
+ },
121
+ "webhookSecret": {
122
+ "type": "string",
123
+ "description": "X-Max-Bot-Api-Secret value, 5-256 of A-Z a-z 0-9 _ -. Prefer webhookSecretFile; generated and kept in the account state file when neither is set."
124
+ },
125
+ "webhookSecretFile": {
126
+ "type": "string",
127
+ "description": "File holding the webhook secret (like tokenFile)."
128
+ },
129
+ "webhookPath": {
130
+ "type": "string",
131
+ "description": "Gateway route path when it differs from the webhookUrl pathname (default: webhookUrl pathname, else /max/webhook)."
132
+ },
133
+ "historyLimit": {
134
+ "type": "integer",
135
+ "minimum": 0
136
+ },
137
+ "dmHistoryLimit": {
138
+ "type": "integer",
139
+ "minimum": 0
140
+ },
141
+ "dms": {
142
+ "type": "object",
143
+ "additionalProperties": {
144
+ "type": "object",
145
+ "properties": {
146
+ "historyLimit": {
147
+ "type": "integer",
148
+ "minimum": 0
149
+ }
150
+ },
151
+ "additionalProperties": false
152
+ }
153
+ },
154
+ "textChunkLimit": {
155
+ "type": "integer",
156
+ "minimum": 1
157
+ },
158
+ "blockStreaming": {
159
+ "type": "boolean"
160
+ },
161
+ "streamMode": {
162
+ "type": "string",
163
+ "enum": [
164
+ "off",
165
+ "partial",
166
+ "block"
167
+ ]
168
+ },
169
+ "blockStreamingCoalesce": {
170
+ "type": "object",
171
+ "properties": {
172
+ "minChars": {
173
+ "type": "integer"
174
+ },
175
+ "maxChars": {
176
+ "type": "integer"
177
+ },
178
+ "idleMs": {
179
+ "type": "integer"
180
+ }
181
+ },
182
+ "additionalProperties": false
183
+ },
184
+ "responsePrefix": {
185
+ "type": "string"
186
+ },
187
+ "mediaMaxMb": {
188
+ "type": "number",
189
+ "exclusiveMinimum": 0
190
+ },
191
+ "markSeen": {
192
+ "type": "boolean"
193
+ },
194
+ "actions": {
195
+ "type": "object",
196
+ "additionalProperties": {
197
+ "anyOf": [
198
+ {
199
+ "type": "boolean"
200
+ },
201
+ {
202
+ "type": "string",
203
+ "enum": [
204
+ "pairing",
205
+ "allowlist",
206
+ "open"
207
+ ]
208
+ }
209
+ ]
210
+ }
211
+ }
212
+ }
213
+ }
214
+ },
215
+ "allOf": [
216
+ {
217
+ "$ref": "#/definitions/accountConfig"
218
+ }
219
+ ],
220
+ "properties": {
221
+ "accounts": {
222
+ "type": "object",
223
+ "additionalProperties": {
224
+ "$ref": "#/definitions/accountConfig"
225
+ }
226
+ },
227
+ "commands": {
228
+ "type": "array",
229
+ "description": "Bot commands registered on start via PATCH /me/commands (replaces the whole list, max 32).",
230
+ "items": {
231
+ "type": "object",
232
+ "properties": {
233
+ "name": {
234
+ "type": "string",
235
+ "maxLength": 64
236
+ },
237
+ "description": {
238
+ "type": "string",
239
+ "maxLength": 128
240
+ }
241
+ },
242
+ "required": [
243
+ "name"
244
+ ],
245
+ "additionalProperties": false
246
+ },
247
+ "maxItems": 32
248
+ },
249
+ "defaultAccount": {
250
+ "type": "string"
251
+ }
252
+ }
253
+ },
254
+ "label": "MAX",
255
+ "description": "MAX messenger channel (max.ru Bot API)."
256
+ }
257
+ },
258
+ "configSchema": {
259
+ "type": "object",
260
+ "additionalProperties": false,
261
+ "properties": {}
262
+ }
263
+ }
package/package.json ADDED
@@ -0,0 +1,100 @@
1
+ {
2
+ "name": "@aspalagin/openclaw-max",
3
+ "version": "0.7.2",
4
+ "description": "MAX messenger channel plugin for OpenClaw",
5
+ "keywords": [
6
+ "openclaw",
7
+ "openclaw-plugin",
8
+ "max",
9
+ "max-messenger",
10
+ "messenger",
11
+ "ai-agent"
12
+ ],
13
+ "license": "MIT",
14
+ "engines": {
15
+ "node": ">=22"
16
+ },
17
+ "type": "module",
18
+ "main": "./dist/index.js",
19
+ "repository": {
20
+ "type": "git",
21
+ "url": "git+https://github.com/aspalagin/openclaw-max.git"
22
+ },
23
+ "homepage": "https://github.com/aspalagin/openclaw-max#readme",
24
+ "bugs": {
25
+ "url": "https://github.com/aspalagin/openclaw-max/issues"
26
+ },
27
+ "scripts": {
28
+ "build": "rm -rf dist-ts dist/src && tsc --noEmit false --noEmitOnError false --outDir dist-ts && mkdir -p dist/src && cp dist-ts/index.js dist-ts/setup-entry.js dist/ && cp dist-ts/src/*.js dist/src/ && rm -f dist/src/*.test.js dist/src/test-setup.js",
29
+ "prepack": "npm run build",
30
+ "typecheck": "tsc --noEmit",
31
+ "lint": "eslint src/",
32
+ "lint:fix": "eslint src/ --fix",
33
+ "format": "prettier --write \"src/**/*.ts\" \"*.ts\"",
34
+ "format:check": "prettier --check \"src/**/*.ts\" \"*.ts\"",
35
+ "test": "vitest run",
36
+ "test:watch": "vitest",
37
+ "test:api": "node --input-type=module ./scripts/test-api.mjs",
38
+ "check:cycles": "node scripts/check-cycles.mjs",
39
+ "schema:update": "node scripts/update-schema.mjs"
40
+ },
41
+ "files": [
42
+ "dist/",
43
+ "src/",
44
+ "!src/**/*.test.ts",
45
+ "!src/test-setup.ts",
46
+ "!src/__fixtures__/",
47
+ "scripts/",
48
+ "openclaw.plugin.json",
49
+ "README.md",
50
+ "SKILL.md",
51
+ "LICENSE"
52
+ ],
53
+ "openclaw": {
54
+ "extensions": [
55
+ "./dist/index.js"
56
+ ],
57
+ "setupEntry": "./dist/setup-entry.js",
58
+ "channel": {
59
+ "id": "max",
60
+ "label": "MAX",
61
+ "selectionLabel": "MAX (max.ru)",
62
+ "docsPath": "/channels/max",
63
+ "docsLabel": "max",
64
+ "blurb": "MAX messenger channel (max.ru Bot API)."
65
+ },
66
+ "compat": {
67
+ "pluginApi": ">=2026.9.6",
68
+ "minGatewayVersion": "2026.9.6"
69
+ },
70
+ "build": {
71
+ "openclawVersion": "2026.9.6",
72
+ "pluginSdkVersion": "2026.9.6"
73
+ }
74
+ },
75
+ "devDependencies": {
76
+ "@types/node": "^25.5.0",
77
+ "@typescript-eslint/eslint-plugin": "^8.61.0",
78
+ "@typescript-eslint/parser": "^8.61.0",
79
+ "eslint": "^9.39.4",
80
+ "eslint-config-prettier": "^10.1.8",
81
+ "eslint-plugin-simple-import-sort": "^13.0.0",
82
+ "openclaw": "^2026.9.6",
83
+ "prettier": "^3.8.4",
84
+ "typescript": "^5.9.3",
85
+ "vitest": "^4.1.8",
86
+ "yaml": "^2.9.1"
87
+ },
88
+ "peerDependencies": {
89
+ "openclaw": ">=2026.9.6"
90
+ },
91
+ "peerDependenciesMeta": {
92
+ "openclaw": {
93
+ "optional": false
94
+ }
95
+ },
96
+ "dependencies": {
97
+ "undici": "^8.7.0",
98
+ "zod": "^4.4.3"
99
+ }
100
+ }
@@ -0,0 +1,38 @@
1
+ // Fails when src/*.ts local imports form a cycle. Type-only imports count too
2
+ // (pass --runtime to ignore them).
3
+ import { readdirSync, readFileSync } from "node:fs";
4
+ import { join } from "node:path";
5
+
6
+ const runtimeOnly = process.argv.includes("--runtime");
7
+ const dir = new URL("../src/", import.meta.url).pathname;
8
+ const files = readdirSync(dir).filter((f) => f.endsWith(".ts") && !f.endsWith(".test.ts"));
9
+ const graph = new Map();
10
+ const importRe = /^\s*(import|export)\s+(type\s+)?[^;]*?from\s+["']\.\/([\w.-]+)\.js["']/gms;
11
+ for (const file of files) {
12
+ const text = readFileSync(join(dir, file), "utf8");
13
+ const deps = new Set();
14
+ for (const m of text.matchAll(importRe)) {
15
+ if (runtimeOnly && m[2]) continue;
16
+ deps.add(`${m[3]}.ts`);
17
+ }
18
+ graph.set(file, [...deps].filter((d) => files.includes(d)));
19
+ }
20
+ const cycles = [];
21
+ const state = new Map();
22
+ const stack = [];
23
+ const visit = (node) => {
24
+ state.set(node, 1);
25
+ stack.push(node);
26
+ for (const dep of graph.get(node) ?? []) {
27
+ if (state.get(dep) === 1) cycles.push([...stack.slice(stack.indexOf(dep)), dep].join(" → "));
28
+ else if (!state.has(dep)) visit(dep);
29
+ }
30
+ stack.pop();
31
+ state.set(node, 2);
32
+ };
33
+ for (const file of files) if (!state.has(file)) visit(file);
34
+ if (cycles.length) {
35
+ console.error(`import cycles (${runtimeOnly ? "runtime" : "all"} imports):\n${cycles.join("\n")}`);
36
+ process.exit(1);
37
+ }
38
+ console.log(`no import cycles among ${files.length} modules (${runtimeOnly ? "runtime" : "all"} imports)`);