@tomingtoming/kioq 0.7.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.
@@ -0,0 +1,447 @@
1
+ import { execFile } from "node:child_process";
2
+ import { promises as fs } from "node:fs";
3
+ import path from "node:path";
4
+ import { promisify } from "node:util";
5
+ import { LocalFsStorage } from "./localFsStorage.js";
6
+ const execFileAsync = promisify(execFile);
7
+ const MAX_RETRIES = 3;
8
+ const RETRY_BASE_MS = 200;
9
+ class GitHubApiError extends Error {
10
+ status;
11
+ endpoint;
12
+ detail;
13
+ retryable;
14
+ retryAfterMs;
15
+ constructor(status, endpoint, detail, retryable, retryAfterMs) {
16
+ super(`GitHub API error (${status}) on ${endpoint}: ${detail}`);
17
+ this.status = status;
18
+ this.endpoint = endpoint;
19
+ this.detail = detail;
20
+ this.retryable = retryable;
21
+ this.retryAfterMs = retryAfterMs;
22
+ this.name = "GitHubApiError";
23
+ }
24
+ }
25
+ class RefUpdateConflictError extends Error {
26
+ constructor() {
27
+ super("GitHub ref update conflict: ref was updated concurrently");
28
+ this.name = "RefUpdateConflictError";
29
+ }
30
+ }
31
+ export class GitHubStorage {
32
+ githubConfig;
33
+ rootPath;
34
+ local;
35
+ apiRoot;
36
+ apiBase;
37
+ branch;
38
+ fetchFn;
39
+ execFileAsyncFn;
40
+ setupValidated = false;
41
+ constructor(githubConfig, rootPath, deps = {}) {
42
+ this.githubConfig = githubConfig;
43
+ this.rootPath = rootPath;
44
+ this.local = new LocalFsStorage(rootPath);
45
+ this.apiRoot = "https://api.github.com";
46
+ this.apiBase = `https://api.github.com/repos/${githubConfig.owner}/${githubConfig.repo}`;
47
+ this.branch = githubConfig.branch;
48
+ this.fetchFn = deps.fetchFn ?? fetch;
49
+ this.execFileAsyncFn = deps.execFileAsyncFn ?? execFileAsync;
50
+ }
51
+ // --- Reads: delegate to local clone ---
52
+ stat(relativePath) {
53
+ return this.local.stat(relativePath);
54
+ }
55
+ readFile(relativePath) {
56
+ return this.local.readFile(relativePath);
57
+ }
58
+ readdir(relativePath) {
59
+ return this.local.readdir(relativePath);
60
+ }
61
+ listMarkdownFiles() {
62
+ return this.local.listMarkdownFiles();
63
+ }
64
+ // --- Writes: GitHub Git Data API ---
65
+ async writeFile(relativePath, content) {
66
+ await this.applyBatch([{ kind: "write", relativePath, content }]);
67
+ }
68
+ async deleteFile(relativePath) {
69
+ await this.applyBatch([{ kind: "delete", relativePath }]);
70
+ }
71
+ async applyBatch(operations, message) {
72
+ if (operations.length === 0) {
73
+ return;
74
+ }
75
+ const commitMessage = message ?? this.generateCommitMessage(operations);
76
+ for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
77
+ try {
78
+ await this.commitViaApi(operations, commitMessage);
79
+ await this.pullLocal("GitHub write succeeded, but local clone sync failed");
80
+ return;
81
+ }
82
+ catch (error) {
83
+ if (error instanceof RefUpdateConflictError && attempt < MAX_RETRIES) {
84
+ await this.waitBeforeRetry(RETRY_BASE_MS * Math.pow(2, attempt));
85
+ await this.tryRefreshLocalClone();
86
+ continue;
87
+ }
88
+ if (this.isRetryableBatchError(error) && attempt < MAX_RETRIES) {
89
+ await this.waitBeforeRetry(this.retryDelayMs(error, attempt));
90
+ await this.tryRefreshLocalClone();
91
+ continue;
92
+ }
93
+ throw error;
94
+ }
95
+ }
96
+ }
97
+ // --- Lifecycle ---
98
+ async ensureRoot() {
99
+ await this.ensureLocalClone();
100
+ await this.validateSetup();
101
+ await this.pullLocal("git pull --ff-only failed during GitHubStorage startup");
102
+ }
103
+ async sync() {
104
+ await this.validateSetup();
105
+ await this.pullLocal("git pull --ff-only failed during GitHubStorage sync");
106
+ }
107
+ // --- Private: setup validation ---
108
+ async ensureLocalClone() {
109
+ const gitDir = path.join(this.rootPath, ".git");
110
+ try {
111
+ const s = await fs.stat(gitDir);
112
+ if (!s.isDirectory()) {
113
+ throw new Error(`${this.rootPath} is not a git repository`);
114
+ }
115
+ }
116
+ catch (error) {
117
+ if (error instanceof Error && error.message.includes("is not a git repository")) {
118
+ throw error;
119
+ }
120
+ throw new Error(`Local clone not found at ${this.rootPath}. `
121
+ + `Clone the repository first: git clone https://github.com/${this.githubConfig.owner}/${this.githubConfig.repo}.git ${this.rootPath}`);
122
+ }
123
+ }
124
+ async validateSetup() {
125
+ if (this.setupValidated) {
126
+ return;
127
+ }
128
+ const remoteUrl = await this.getOriginRemoteUrl();
129
+ if (!this.remoteMatchesConfiguredRepo(remoteUrl)) {
130
+ throw new Error(`Local clone origin mismatch: expected ${this.repoSlug()}, got ${remoteUrl}`);
131
+ }
132
+ const viewer = await this.validateToken();
133
+ const repo = await this.validateRepositoryAccess(viewer.login);
134
+ await this.validateBranch(repo.default_branch);
135
+ this.setupValidated = true;
136
+ }
137
+ // --- Private: Git Data API flow ---
138
+ async commitViaApi(operations, message) {
139
+ const ref = await this.apiRequest("GET", `/git/ref/heads/${this.branch}`);
140
+ const parentCommitSha = ref.object.sha;
141
+ const parentCommit = await this.apiRequest("GET", `/git/commits/${parentCommitSha}`);
142
+ const baseTreeSha = parentCommit.tree.sha;
143
+ const writes = operations.filter((op) => op.kind === "write");
144
+ const deletes = operations.filter((op) => op.kind === "delete");
145
+ const blobResults = await Promise.all(writes.map(async (op) => {
146
+ const blob = await this.apiRequest("POST", "/git/blobs", { content: op.content, encoding: "utf-8" });
147
+ return { repoPath: op.relativePath, sha: blob.sha };
148
+ }));
149
+ let newTreeSha;
150
+ if (deletes.length === 0) {
151
+ const treeEntries = blobResults.map(({ repoPath, sha }) => ({
152
+ path: repoPath,
153
+ mode: "100644",
154
+ type: "blob",
155
+ sha,
156
+ }));
157
+ const tree = await this.apiRequest("POST", "/git/trees", { base_tree: baseTreeSha, tree: treeEntries });
158
+ newTreeSha = tree.sha;
159
+ }
160
+ else {
161
+ const fullTree = await this.apiRequest("GET", `/git/trees/${baseTreeSha}?recursive=1`);
162
+ if (fullTree.truncated) {
163
+ throw new Error("Repository tree is too large for the GitHub API (truncated response)");
164
+ }
165
+ const deleteSet = new Set(deletes.map((op) => op.relativePath));
166
+ const writeMap = new Map(blobResults.map(({ repoPath, sha }) => [repoPath, sha]));
167
+ const surviving = fullTree.tree
168
+ .filter((entry) => entry.type === "blob")
169
+ .filter((entry) => !deleteSet.has(entry.path))
170
+ .filter((entry) => !writeMap.has(entry.path))
171
+ .map((entry) => ({
172
+ path: entry.path,
173
+ mode: entry.mode,
174
+ type: "blob",
175
+ sha: entry.sha,
176
+ }));
177
+ const writeEntries = blobResults.map(({ repoPath, sha }) => ({
178
+ path: repoPath,
179
+ mode: "100644",
180
+ type: "blob",
181
+ sha,
182
+ }));
183
+ const tree = await this.apiRequest("POST", "/git/trees", { tree: [...surviving, ...writeEntries] });
184
+ newTreeSha = tree.sha;
185
+ }
186
+ const commit = await this.apiRequest("POST", "/git/commits", {
187
+ message,
188
+ tree: newTreeSha,
189
+ parents: [parentCommitSha],
190
+ });
191
+ await this.apiRequest("PATCH", `/git/refs/heads/${this.branch}`, { sha: commit.sha });
192
+ }
193
+ // --- Private: helpers ---
194
+ generateCommitMessage(operations) {
195
+ if (operations.length === 1) {
196
+ const op = operations[0];
197
+ return `kioq: ${op.kind} ${op.relativePath}`;
198
+ }
199
+ const writes = operations.filter((op) => op.kind === "write").length;
200
+ const deletions = operations.filter((op) => op.kind === "delete").length;
201
+ const parts = [];
202
+ if (writes > 0) {
203
+ parts.push(`write ${writes} file${writes > 1 ? "s" : ""}`);
204
+ }
205
+ if (deletions > 0) {
206
+ parts.push(`delete ${deletions} file${deletions > 1 ? "s" : ""}`);
207
+ }
208
+ return `kioq: ${parts.join(", ")}`;
209
+ }
210
+ async apiRequest(method, apiPath, body, options = {}) {
211
+ const url = apiPath.startsWith("https://") ? apiPath : `${this.apiBase}${apiPath}`;
212
+ const maxAttempts = options.retryTransient ? MAX_RETRIES + 1 : 1;
213
+ for (let attempt = 0; attempt < maxAttempts; attempt += 1) {
214
+ let response;
215
+ try {
216
+ response = await this.fetchFn(url, {
217
+ method,
218
+ headers: this.buildHeaders(options.useAuth ?? true),
219
+ body: body ? JSON.stringify(body) : undefined,
220
+ });
221
+ }
222
+ catch (error) {
223
+ if (options.retryTransient && this.isTransientNetworkError(error) && attempt < maxAttempts - 1) {
224
+ await this.waitBeforeRetry(this.retryDelayMs(error, attempt));
225
+ continue;
226
+ }
227
+ throw error;
228
+ }
229
+ if (!response.ok) {
230
+ if (response.status === 422 && apiPath.startsWith("/git/refs/")) {
231
+ throw new RefUpdateConflictError();
232
+ }
233
+ const text = await response.text();
234
+ const retryAfterMs = this.retryAfterMs(response.headers, text);
235
+ const retryable = this.isRetryableStatus(response.status, response.headers, text);
236
+ if (options.retryTransient && retryable && attempt < maxAttempts - 1) {
237
+ await this.waitBeforeRetry(retryAfterMs ?? this.retryDelayMs(undefined, attempt));
238
+ continue;
239
+ }
240
+ throw new GitHubApiError(response.status, `${method} ${apiPath}`, this.describeApiError(text), retryable, retryAfterMs);
241
+ }
242
+ return response.json();
243
+ }
244
+ throw new Error(`GitHub API request exhausted retries: ${method} ${apiPath}`);
245
+ }
246
+ buildHeaders(useAuth) {
247
+ const headers = {
248
+ "Accept": "application/vnd.github+json",
249
+ "X-GitHub-Api-Version": "2022-11-28",
250
+ "Content-Type": "application/json",
251
+ };
252
+ if (useAuth) {
253
+ headers.Authorization = `Bearer ${this.githubConfig.token}`;
254
+ }
255
+ return headers;
256
+ }
257
+ async validateToken() {
258
+ try {
259
+ return await this.apiRequest("GET", `${this.apiRoot}/user`, undefined, { retryTransient: true });
260
+ }
261
+ catch (error) {
262
+ if (error instanceof GitHubApiError) {
263
+ if (error.status === 401) {
264
+ throw new Error("GitHub token is invalid or expired");
265
+ }
266
+ if (error.status === 403) {
267
+ throw new Error(`GitHub token was rejected during authentication: ${error.detail}`);
268
+ }
269
+ }
270
+ throw error;
271
+ }
272
+ }
273
+ async validateRepositoryAccess(viewerLogin) {
274
+ try {
275
+ const repo = await this.apiRequest("GET", "", undefined, { retryTransient: true });
276
+ if (repo.permissions && repo.permissions.push !== true) {
277
+ throw new Error(`GitHub token can read ${this.repoSlug()}, but does not have push access for writes`);
278
+ }
279
+ return repo;
280
+ }
281
+ catch (error) {
282
+ if (error instanceof GitHubApiError && error.status === 404) {
283
+ const publicRepoExists = await this.publicRepositoryExists();
284
+ if (publicRepoExists) {
285
+ throw new Error(`GitHub token cannot access ${this.repoSlug()}. Check repo access or token scopes for ${viewerLogin}`);
286
+ }
287
+ throw new Error(`GitHub repository ${this.repoSlug()} was not found, or the token cannot access it if it is private`);
288
+ }
289
+ throw error;
290
+ }
291
+ }
292
+ async validateBranch(defaultBranch) {
293
+ try {
294
+ return await this.apiRequest("GET", `/branches/${encodeURIComponent(this.branch)}`, undefined, { retryTransient: true });
295
+ }
296
+ catch (error) {
297
+ if (error instanceof GitHubApiError && error.status === 404) {
298
+ if (defaultBranch && defaultBranch !== this.branch) {
299
+ throw new Error(`GitHub branch "${this.branch}" was not found in ${this.repoSlug()} (default branch: "${defaultBranch}")`);
300
+ }
301
+ throw new Error(`GitHub branch "${this.branch}" was not found in ${this.repoSlug()}`);
302
+ }
303
+ throw error;
304
+ }
305
+ }
306
+ async publicRepositoryExists() {
307
+ try {
308
+ await this.apiRequest("GET", "", undefined, {
309
+ useAuth: false,
310
+ retryTransient: true,
311
+ });
312
+ return true;
313
+ }
314
+ catch (error) {
315
+ if (error instanceof GitHubApiError && error.status === 404) {
316
+ return false;
317
+ }
318
+ return false;
319
+ }
320
+ }
321
+ async getOriginRemoteUrl() {
322
+ try {
323
+ const { stdout } = await this.execFileAsyncFn("git", ["remote", "get-url", "origin"], {
324
+ cwd: this.rootPath,
325
+ timeout: 15_000,
326
+ });
327
+ const remoteUrl = stdout.trim();
328
+ if (remoteUrl.length === 0) {
329
+ throw new Error("origin remote is empty");
330
+ }
331
+ return remoteUrl;
332
+ }
333
+ catch (error) {
334
+ throw new Error(`Failed to read git origin remote in ${this.rootPath}: ${this.normalizeExecError(error)}`);
335
+ }
336
+ }
337
+ remoteMatchesConfiguredRepo(remoteUrl) {
338
+ const normalizedRemote = remoteUrl.trim().replace(/\.git$/i, "").toLowerCase();
339
+ const slug = this.repoSlug().toLowerCase();
340
+ return normalizedRemote.endsWith(`/${slug}`) || normalizedRemote.endsWith(`:${slug}`);
341
+ }
342
+ repoSlug() {
343
+ return `${this.githubConfig.owner}/${this.githubConfig.repo}`;
344
+ }
345
+ async pullLocal(context) {
346
+ try {
347
+ await this.execFileAsyncFn("git", ["pull", "--ff-only"], {
348
+ cwd: this.rootPath,
349
+ timeout: 30_000,
350
+ });
351
+ }
352
+ catch (error) {
353
+ throw new Error(`${context}: ${this.normalizeExecError(error)}`);
354
+ }
355
+ }
356
+ async tryRefreshLocalClone() {
357
+ try {
358
+ await this.pullLocal("git pull --ff-only failed while refreshing local clone");
359
+ }
360
+ catch {
361
+ // best-effort only
362
+ }
363
+ }
364
+ isRetryableBatchError(error) {
365
+ return (error instanceof GitHubApiError && error.retryable)
366
+ || this.isTransientNetworkError(error);
367
+ }
368
+ retryDelayMs(error, attempt) {
369
+ if (error instanceof GitHubApiError && error.retryAfterMs !== undefined) {
370
+ return error.retryAfterMs;
371
+ }
372
+ return RETRY_BASE_MS * Math.pow(2, attempt);
373
+ }
374
+ isRetryableStatus(status, headers, detail) {
375
+ if ([408, 429, 500, 502, 503, 504].includes(status)) {
376
+ return true;
377
+ }
378
+ if (status === 403) {
379
+ const remaining = headers.get("x-ratelimit-remaining");
380
+ if (remaining === "0") {
381
+ return true;
382
+ }
383
+ const normalizedDetail = detail.toLowerCase();
384
+ if (normalizedDetail.includes("secondary rate limit")) {
385
+ return true;
386
+ }
387
+ }
388
+ return false;
389
+ }
390
+ retryAfterMs(headers, detail) {
391
+ const retryAfter = headers.get("retry-after");
392
+ if (retryAfter) {
393
+ const seconds = Number(retryAfter);
394
+ if (Number.isFinite(seconds) && seconds >= 0) {
395
+ return seconds * 1000;
396
+ }
397
+ }
398
+ const resetAt = headers.get("x-ratelimit-reset");
399
+ if (resetAt) {
400
+ const seconds = Number(resetAt);
401
+ if (Number.isFinite(seconds) && seconds > 0) {
402
+ const delayMs = (seconds * 1000) - Date.now();
403
+ if (delayMs > 0) {
404
+ return delayMs;
405
+ }
406
+ }
407
+ }
408
+ if (detail.toLowerCase().includes("secondary rate limit")) {
409
+ return 5_000;
410
+ }
411
+ return undefined;
412
+ }
413
+ isTransientNetworkError(error) {
414
+ if (!(error instanceof Error)) {
415
+ return false;
416
+ }
417
+ const detail = `${error.name}: ${error.message}`;
418
+ return /fetch failed|network|timeout|timed out|econnreset|enotfound|eai_again/i.test(detail);
419
+ }
420
+ describeApiError(text) {
421
+ try {
422
+ const parsed = JSON.parse(text);
423
+ const parts = [parsed.message, ...(parsed.errors ?? []).map((item) => item.message)]
424
+ .filter((value) => typeof value === "string" && value.trim().length > 0);
425
+ if (parts.length > 0) {
426
+ return parts.join(" | ");
427
+ }
428
+ }
429
+ catch {
430
+ // fall back to raw text
431
+ }
432
+ const trimmed = text.trim();
433
+ return trimmed.length > 0 ? trimmed : "unknown error";
434
+ }
435
+ normalizeExecError(error) {
436
+ if (!(error instanceof Error)) {
437
+ return String(error);
438
+ }
439
+ const stdout = "stdout" in error && typeof error.stdout === "string" ? error.stdout.trim() : "";
440
+ const stderr = "stderr" in error && typeof error.stderr === "string" ? error.stderr.trim() : "";
441
+ const parts = [error.message, stderr, stdout].filter((value) => value.length > 0);
442
+ return parts.join(" | ");
443
+ }
444
+ async waitBeforeRetry(delayMs) {
445
+ await new Promise((resolve) => setTimeout(resolve, delayMs));
446
+ }
447
+ }
@@ -0,0 +1,3 @@
1
+ export { GitHubStorage } from "./githubStorage.js";
2
+ export { LocalFsStorage } from "./localFsStorage.js";
3
+ export { toPosix } from "./types.js";
@@ -0,0 +1,125 @@
1
+ import { promises as fs } from "node:fs";
2
+ import path from "node:path";
3
+ import { toPosix } from "./types.js";
4
+ const IGNORED_DIRS = new Set([".git", "node_modules", ".obsidian"]);
5
+ const MARKDOWN_EXTENSION = ".md";
6
+ export class LocalFsStorage {
7
+ rootPath;
8
+ constructor(rootPath) {
9
+ this.rootPath = rootPath;
10
+ }
11
+ async stat(relativePath) {
12
+ try {
13
+ const s = await fs.stat(this.resolve(relativePath));
14
+ return {
15
+ exists: true,
16
+ isFile: s.isFile(),
17
+ isDirectory: s.isDirectory(),
18
+ birthtime: s.birthtime,
19
+ mtime: s.mtime,
20
+ };
21
+ }
22
+ catch {
23
+ return { exists: false, isFile: false, isDirectory: false };
24
+ }
25
+ }
26
+ async readFile(relativePath) {
27
+ return fs.readFile(this.resolve(relativePath), "utf8");
28
+ }
29
+ async readdir(relativePath) {
30
+ const entries = await fs.readdir(this.resolve(relativePath), { withFileTypes: true });
31
+ return entries.map((entry) => ({
32
+ name: entry.name,
33
+ isFile: entry.isFile(),
34
+ isDirectory: entry.isDirectory(),
35
+ }));
36
+ }
37
+ async listMarkdownFiles() {
38
+ const files = [];
39
+ const queue = [this.rootPath];
40
+ while (queue.length > 0) {
41
+ const currentDir = queue.pop();
42
+ if (!currentDir) {
43
+ continue;
44
+ }
45
+ let entries;
46
+ try {
47
+ entries = await fs.readdir(currentDir, { withFileTypes: true });
48
+ }
49
+ catch {
50
+ continue;
51
+ }
52
+ for (const entry of entries) {
53
+ const entryPath = path.join(currentDir, entry.name);
54
+ if (entry.isDirectory()) {
55
+ if (IGNORED_DIRS.has(entry.name) || entry.name.startsWith(".")) {
56
+ continue;
57
+ }
58
+ queue.push(entryPath);
59
+ continue;
60
+ }
61
+ if (entry.isFile() && entry.name.endsWith(MARKDOWN_EXTENSION)) {
62
+ files.push(toPosix(path.relative(this.rootPath, entryPath)));
63
+ }
64
+ }
65
+ }
66
+ return files;
67
+ }
68
+ async applyBatch(operations) {
69
+ for (const op of operations) {
70
+ switch (op.kind) {
71
+ case "write":
72
+ await this.writeFile(op.relativePath, op.content);
73
+ break;
74
+ case "delete":
75
+ await this.deleteFile(op.relativePath);
76
+ break;
77
+ }
78
+ }
79
+ }
80
+ async writeFile(relativePath, content) {
81
+ const absPath = this.resolve(relativePath);
82
+ await fs.mkdir(path.dirname(absPath), { recursive: true });
83
+ await fs.writeFile(absPath, content, "utf8");
84
+ }
85
+ async deleteFile(relativePath) {
86
+ const absPath = this.resolve(relativePath);
87
+ await fs.unlink(absPath);
88
+ await this.pruneEmptyDirectories(path.dirname(absPath));
89
+ }
90
+ async ensureRoot() {
91
+ await fs.mkdir(this.rootPath, { recursive: true });
92
+ }
93
+ async sync() {
94
+ // no-op for local storage
95
+ }
96
+ resolve(relativePath) {
97
+ if (relativePath === "" || relativePath === ".") {
98
+ return this.rootPath;
99
+ }
100
+ return path.resolve(this.rootPath, relativePath);
101
+ }
102
+ async pruneEmptyDirectories(startDir) {
103
+ const normalizedStop = path.resolve(this.rootPath);
104
+ let current = path.resolve(startDir);
105
+ while (current !== normalizedStop && current.startsWith(`${normalizedStop}${path.sep}`)) {
106
+ let entries;
107
+ try {
108
+ entries = await fs.readdir(current);
109
+ }
110
+ catch {
111
+ return;
112
+ }
113
+ if (entries.length > 0) {
114
+ return;
115
+ }
116
+ try {
117
+ await fs.rmdir(current);
118
+ }
119
+ catch {
120
+ return;
121
+ }
122
+ current = path.dirname(current);
123
+ }
124
+ }
125
+ }
@@ -0,0 +1,4 @@
1
+ import path from "node:path";
2
+ export function toPosix(value) {
3
+ return value.split(path.sep).join("/");
4
+ }
@@ -0,0 +1,20 @@
1
+ export const TOOL_SCOPE_LABELS = {
2
+ list_projects: "project_catalog",
3
+ recent_notes: "recent_listing",
4
+ search_notes: "search_listing",
5
+ read_note: "direct_read",
6
+ resolve_links: "link_audit",
7
+ list_backlinks: "backlink_audit",
8
+ context_bundle: "context_navigation",
9
+ memory_contract: "contract_reference",
10
+ lint_structure: "structure_audit",
11
+ write_note: "generic_write",
12
+ write_flow_note: "flow_write",
13
+ promote_to_stock: "flow_promotion",
14
+ append_note: "append_write",
15
+ delete_note: "delete_lifecycle",
16
+ rename_note: "rename_lifecycle",
17
+ };
18
+ export function responsibilityWarningCode(warnings) {
19
+ return warnings[0]?.code ?? "none";
20
+ }
@@ -0,0 +1 @@
1
+ export {};