runwork 0.9.1 → 0.9.3

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.
@@ -4,7 +4,7 @@ import { join } from 'path';
4
4
  import { homedir } from 'os';
5
5
  import { requireAuth } from '../auth/store.js';
6
6
  import { ApiClient } from '../api/client.js';
7
- import { getAdapterBySlug } from '../agents/detect.js';
7
+ import { getAdapterBySlug, detectAgents } from '../agents/detect.js';
8
8
  import { CodexAdapter } from '../agents/codex.js';
9
9
  import { RUNWORK_MCP_PREFIX, RUNWORK_WORKSPACE_MCP_NAME } from '../agents/types.js';
10
10
  import { collectTelemetryEvents, printTelemetryVerbose, summarizeTelemetryForDryRun, } from './sync-telemetry.js';
@@ -60,6 +60,49 @@ function readLocalSkills(state) {
60
60
  }
61
61
  return results;
62
62
  }
63
+ /**
64
+ * How long a detection result stays trusted before sync re-runs detection
65
+ * on its own. Detection is cheap on macOS/Linux but pays a PowerShell
66
+ * waterfall on Windows, so we cache for a day. Users who just installed
67
+ * a new agent can shortcut the wait via `--redetect` (or the desktop's
68
+ * "Rescan" button, which forwards the flag).
69
+ */
70
+ export const REDETECT_TTL_MS = 24 * 60 * 60 * 1000;
71
+ /**
72
+ * Decide whether to re-run installation detection for this sync. We always
73
+ * re-detect on explicit user request, on first sync (no timestamp yet), when
74
+ * setup ended up with zero agents (recovery), or after the TTL has lapsed.
75
+ */
76
+ export function shouldRedetect(state, opts) {
77
+ if (opts.redetect)
78
+ return true;
79
+ if (state.configuredAgents.length === 0)
80
+ return true;
81
+ if (!state.lastDetectedAt)
82
+ return true;
83
+ const last = Date.parse(state.lastDetectedAt);
84
+ if (Number.isNaN(last))
85
+ return true;
86
+ return Date.now() - last > REDETECT_TTL_MS;
87
+ }
88
+ /**
89
+ * Detect installed agents and merge any new slugs into `state.configuredAgents`.
90
+ * Never removes agents — a transient PATH glitch shouldn't wipe the setup.
91
+ * Returns the slugs that were newly added (for logging).
92
+ */
93
+ export async function refreshConfiguredAgents(state) {
94
+ const detected = await detectAgents();
95
+ const before = new Set(state.configuredAgents);
96
+ const added = [];
97
+ for (const adapter of detected) {
98
+ if (!before.has(adapter.slug)) {
99
+ state.configuredAgents.push(adapter.slug);
100
+ added.push(adapter.slug);
101
+ }
102
+ }
103
+ state.lastDetectedAt = new Date().toISOString();
104
+ return added;
105
+ }
63
106
  export async function syncFromState(state, statePath, credentials, opts) {
64
107
  const client = new ApiClient(credentials);
65
108
  // Refresh workspace name/slug if missing or stale (e.g. setup ran before name was set)
@@ -77,6 +120,16 @@ export async function syncFromState(state, statePath, credentials, opts) {
77
120
  }
78
121
  }
79
122
  console.log(`Syncing workspace: ${state.workspaceName || state.workspaceId}`);
123
+ // Refresh the configured-agents list when the cached detection is stale,
124
+ // empty, or the user explicitly asked. This is what catches "I just installed
125
+ // Claude Code on this machine" without needing the user to re-run setup.
126
+ if (shouldRedetect(state, opts)) {
127
+ console.log(' Detecting installed agents...');
128
+ const added = await refreshConfiguredAgents(state);
129
+ if (added.length > 0) {
130
+ console.log(` Detected new agent${added.length > 1 ? 's' : ''}: ${added.join(', ')}`);
131
+ }
132
+ }
80
133
  console.log(' Fetching workspace data...');
81
134
  // Fetch latest data (includeContent=true gets all skill content in one request)
82
135
  const [allSkills, mcpServers, externalSkills, registries, connectedIntegrations] = await Promise.all([
@@ -436,6 +489,7 @@ export const syncCommand = new Command('sync')
436
489
  .option('--pull-only', 'Only pull remote changes, never push local edits')
437
490
  .option('--prefer <side>', 'Auto-resolve conflicts: "local" or "remote" (implies --yes)')
438
491
  .option('-y, --yes', 'Non-interactive mode (default: prefer remote for conflicts)')
492
+ .option('--redetect', 'Force agent-installation re-detection (picks up agents installed since last setup)')
439
493
  .action(async (opts) => {
440
494
  const credentials = requireAuth();
441
495
  const syncOpts = {
@@ -444,6 +498,7 @@ export const syncCommand = new Command('sync')
444
498
  yes: !!opts.yes || !!opts.prefer,
445
499
  prefer: opts.prefer,
446
500
  verbose: !!opts.verbose,
501
+ redetect: !!opts.redetect,
447
502
  };
448
503
  const projectStatePath = join(process.cwd(), '.runwork', 'setup.json');
449
504
  const userStatePath = join(homedir(), '.runwork', 'setup.json');
@@ -0,0 +1,39 @@
1
+ /**
2
+ * Developer tool registry — non-AI prerequisites Runwork users need installed
3
+ * locally (git today, more later if needed).
4
+ *
5
+ * Extends the shared `InstallableTool` base so the desktop can detect and
6
+ * render devtools alongside AI agents using the same icon/detection plumbing.
7
+ *
8
+ * Browser-safe: no node-only imports.
9
+ */
10
+ import type { InstallableTool } from '../tools/types.js';
11
+ /**
12
+ * How the desktop should attempt to install the tool. Each platform either
13
+ * has an automated install command (run via the platform shell) or falls
14
+ * back to opening the tool's `downloadUrl` in the browser.
15
+ */
16
+ export type DevToolInstallStrategy = {
17
+ kind: 'shell';
18
+ shell: 'powershell' | 'zsh' | 'bash';
19
+ command: string;
20
+ postInstallNote?: string;
21
+ } | {
22
+ kind: 'open-url';
23
+ url: string;
24
+ };
25
+ export interface DevToolDefinition extends InstallableTool {
26
+ /** Beginner-friendly explanation shown in the install card. */
27
+ longDescription: string;
28
+ /**
29
+ * Per-platform install strategy. Missing platforms fall back to opening
30
+ * `downloadUrl` in the browser.
31
+ */
32
+ install: {
33
+ macos?: DevToolInstallStrategy;
34
+ windows?: DevToolInstallStrategy;
35
+ linux?: DevToolInstallStrategy;
36
+ };
37
+ }
38
+ export declare function getDevTools(): DevToolDefinition[];
39
+ export declare function getDevTool(slug: string): DevToolDefinition | undefined;
@@ -0,0 +1,39 @@
1
+ /**
2
+ * Developer tool registry — non-AI prerequisites Runwork users need installed
3
+ * locally (git today, more later if needed).
4
+ *
5
+ * Extends the shared `InstallableTool` base so the desktop can detect and
6
+ * render devtools alongside AI agents using the same icon/detection plumbing.
7
+ *
8
+ * Browser-safe: no node-only imports.
9
+ */
10
+ const DEV_TOOL_REGISTRY = [
11
+ {
12
+ slug: 'git',
13
+ name: 'Git',
14
+ description: 'Version control system',
15
+ longDescription: 'Git tracks changes to your code and is required for Runwork commands that sync your work with the platform (init, clone, dev, deploy).',
16
+ detection: { method: 'binary', target: 'git' },
17
+ downloadUrl: 'https://git-scm.com/downloads',
18
+ install: {
19
+ windows: {
20
+ kind: 'shell',
21
+ shell: 'powershell',
22
+ command: 'winget install --id Git.Git -e --source winget --accept-package-agreements --accept-source-agreements',
23
+ },
24
+ macos: {
25
+ kind: 'shell',
26
+ shell: 'zsh',
27
+ command: 'xcode-select --install',
28
+ postInstallNote: 'A macOS installer window has opened. Finish the install there, then click Re-detect.',
29
+ },
30
+ linux: { kind: 'open-url', url: 'https://git-scm.com/download/linux' },
31
+ },
32
+ },
33
+ ];
34
+ export function getDevTools() {
35
+ return DEV_TOOL_REGISTRY;
36
+ }
37
+ export function getDevTool(slug) {
38
+ return DEV_TOOL_REGISTRY.find((t) => t.slug === slug);
39
+ }
@@ -1 +1 @@
1
- export declare const VERSION = "0.9.1";
1
+ export declare const VERSION = "0.9.3";
@@ -1,2 +1,2 @@
1
1
  // Auto-generated by scripts/embed-types.ts -- do not edit
2
- export const VERSION = "0.9.1";
2
+ export const VERSION = "0.9.3";
@@ -67,6 +67,47 @@ describe('generateManifest()', () => {
67
67
  expect(Object.keys(manifest.files)).toHaveLength(1);
68
68
  expect(manifest.files['a.txt']).toBeDefined();
69
69
  });
70
+ it('skips .bun-cache directory by default (regression: hung dev startup)', async () => {
71
+ // Bun's per-project cache used to be walked recursively here. When it
72
+ // grew to ~1 GB / 25k+ files, generateManifest spent minutes hashing
73
+ // every file and starved the rest of `runwork dev`. The default
74
+ // ignore-matcher now skips .bun-cache/ even without a .gitignore.
75
+ const dir = makeTempDir('bun-cache');
76
+ writeFileSync(join(dir, 'a.txt'), 'hello');
77
+ mkdirSync(join(dir, '.bun-cache'));
78
+ writeFileSync(join(dir, '.bun-cache', 'pkg.tgz'), 'tarball');
79
+ mkdirSync(join(dir, '.bun-cache', 'react@19.0.0'));
80
+ writeFileSync(join(dir, '.bun-cache', 'react@19.0.0', 'index.js'), 'r');
81
+ const manifest = await generateManifest(dir);
82
+ expect(Object.keys(manifest.files)).toEqual(['a.txt']);
83
+ });
84
+ it('honors simple .gitignore directory entries (e.g. dist/, .next/, coverage)', async () => {
85
+ const dir = makeTempDir('gitignored-dirs');
86
+ writeFileSync(join(dir, '.gitignore'), 'dist/\n.next/\ncoverage\n');
87
+ writeFileSync(join(dir, 'a.txt'), 'hello');
88
+ mkdirSync(join(dir, 'dist'));
89
+ writeFileSync(join(dir, 'dist', 'bundle.js'), 'compiled');
90
+ mkdirSync(join(dir, '.next'));
91
+ writeFileSync(join(dir, '.next', 'build.json'), '{}');
92
+ mkdirSync(join(dir, 'coverage'));
93
+ writeFileSync(join(dir, 'coverage', 'lcov.info'), 'data');
94
+ const manifest = await generateManifest(dir);
95
+ const keys = Object.keys(manifest.files).sort();
96
+ expect(keys).toEqual(['.gitignore', 'a.txt']);
97
+ });
98
+ it('falls through to defaults when .gitignore patterns are too complex', async () => {
99
+ // The walker only honors simple directory/basename entries. Globbed
100
+ // patterns are skipped, so files matching them still appear in the
101
+ // manifest. This is acceptable: the goal is to avoid the runaway
102
+ // cases (huge gitignored caches), not to be a full gitignore matcher.
103
+ const dir = makeTempDir('complex-gitignore');
104
+ writeFileSync(join(dir, '.gitignore'), '*.local\nsrc/generated/\n');
105
+ writeFileSync(join(dir, 'a.txt'), 'hello');
106
+ writeFileSync(join(dir, 'config.local'), 'should-still-appear');
107
+ const manifest = await generateManifest(dir);
108
+ expect(manifest.files['a.txt']).toBeDefined();
109
+ expect(manifest.files['config.local']).toBeDefined();
110
+ });
70
111
  it('handles files with spaces in names', async () => {
71
112
  const dir = makeTempDir('spaces');
72
113
  writeFileSync(join(dir, 'hello world.txt'), 'content');
@@ -374,4 +415,32 @@ describe('detectUserEdits() edge cases', () => {
374
415
  const edits = await detectUserEdits(dir, manifest);
375
416
  expect(edits).not.toContain('config.txt');
376
417
  });
418
+ it('does not flag tracked file whose committed content differs from template manifest', async () => {
419
+ // Reproduces the post-`runwork clone` scenario: the manifest was
420
+ // generated from the pristine template, then the AI-customized
421
+ // version was overlaid via `git fetch` + `git checkout .` and is now
422
+ // committed at HEAD with no local edits. Method 3's scan would
423
+ // false-flag every such file on every clone if it didn't skip
424
+ // tracked files.
425
+ const dir = makeTempDir('clone-overlay');
426
+ initGitRepo(dir);
427
+ // Simulate: AI-customized version is committed at HEAD.
428
+ const overlaidContent = 'AI-customized content';
429
+ writeFileSync(join(dir, 'worker/agents.ts'.replace('/', '-')), 'placeholder');
430
+ const aiFilePath = 'agents.ts';
431
+ writeFileSync(join(dir, aiFilePath), overlaidContent);
432
+ execFileSync('git', ['add', '.'], { cwd: dir });
433
+ execFileSync('git', ['commit', '-m', 'overlay'], { cwd: dir });
434
+ // Manifest captured the PRISTINE template hash (different from the
435
+ // overlaid HEAD content). detectUserEdits must not treat this as a
436
+ // user edit.
437
+ const manifest = {
438
+ version: 1,
439
+ files: {
440
+ [aiFilePath]: 'sha256:pristine_template_hash_unrelated_to_disk',
441
+ },
442
+ };
443
+ const edits = await detectUserEdits(dir, manifest);
444
+ expect(edits).not.toContain(aiFilePath);
445
+ });
377
446
  });
@@ -1,4 +1,9 @@
1
1
  import type { ApiClient } from '../api/client.js';
2
+ /**
3
+ * Basename-level ignore predicate using the static defaults only.
4
+ * Exposed for unit testing of the static defaults; the runtime watcher uses
5
+ * a richer matcher built from `.gitignore` (see watchAndAutoCommit).
6
+ */
2
7
  export declare function isIgnored(filePath: string): boolean;
3
8
  export declare function watchAndAutoCommit(directory: string, client: ApiClient, appId: string, callbacks?: {
4
9
  onFileChange?: (relPath: string, pendingCount: number) => void;
@@ -1,8 +1,9 @@
1
1
  import { execFileSync } from 'child_process';
2
2
  import { readFileSync } from 'fs';
3
3
  import { watch } from 'chokidar';
4
- import { basename, join, relative } from 'path';
4
+ import { join, relative } from 'path';
5
5
  import { dim, cyan, yellow } from '../ui/colors.js';
6
+ import { buildIgnoreSets, defaultIgnoreSets, isPathIgnored } from '../utils/ignore-matcher.js';
6
7
  let watcher = null;
7
8
  let fastSyncTimer = null;
8
9
  let gitTimer = null;
@@ -13,9 +14,6 @@ let activeCallbacks;
13
14
  const pendingFastSync = new Map();
14
15
  // Track files the user actually touched during this session (for git)
15
16
  const changedFiles = new Set();
16
- const SKIP_DIRS = new Set(['node_modules', '.git', '.runwork']);
17
- const SKIP_FILES = new Set(['.dev.vars', '.env']);
18
- const SKIP_EXTENSIONS = new Set(['.log']);
19
17
  // Binary extensions to skip in fast sync (git handles them fine)
20
18
  const BINARY_EXTENSIONS = new Set([
21
19
  '.png', '.jpg', '.jpeg', '.gif', '.ico', '.webp', '.avif', '.svg',
@@ -24,21 +22,24 @@ const BINARY_EXTENSIONS = new Set([
24
22
  '.zip', '.tar', '.gz', '.br',
25
23
  '.pdf', '.wasm',
26
24
  ]);
25
+ const STATIC_IGNORE_SETS = defaultIgnoreSets();
26
+ /**
27
+ * Basename-level ignore predicate using the static defaults only.
28
+ * Exposed for unit testing of the static defaults; the runtime watcher uses
29
+ * a richer matcher built from `.gitignore` (see watchAndAutoCommit).
30
+ */
27
31
  export function isIgnored(filePath) {
28
- const name = basename(filePath);
29
- if (SKIP_DIRS.has(name))
30
- return true;
31
- if (SKIP_FILES.has(name))
32
- return true;
33
- const ext = name.lastIndexOf('.') >= 0 ? name.slice(name.lastIndexOf('.')) : '';
34
- if (SKIP_EXTENSIONS.has(ext))
35
- return true;
36
- return false;
32
+ return isPathIgnored(filePath, STATIC_IGNORE_SETS);
37
33
  }
38
34
  export async function watchAndAutoCommit(directory, client, appId, callbacks) {
39
35
  activeCallbacks = callbacks;
36
+ // Build a project-scoped ignore matcher that includes simple patterns from
37
+ // the project's .gitignore in addition to the static defaults. Without
38
+ // this the watcher tries to descend into large gitignored caches such as
39
+ // `.bun-cache/` or `dist/` and bogs the CLI down on startup.
40
+ const ignoreSets = buildIgnoreSets(directory);
40
41
  watcher = watch(directory, {
41
- ignored: isIgnored,
42
+ ignored: (p) => isPathIgnored(p, ignoreSets),
42
43
  persistent: true,
43
44
  ignoreInitial: true,
44
45
  awaitWriteFinish: {
@@ -6,11 +6,22 @@ import { getCredentials } from '../auth/store.js';
6
6
  */
7
7
  export async function configureGitCredentials(remoteUrl) {
8
8
  const origin = new URL(remoteUrl).origin;
9
- execFileSync('git', [
10
- 'config', '--global',
11
- `credential.${origin}.helper`,
12
- '!runwork git-credential-helper',
13
- ]);
9
+ try {
10
+ execFileSync('git', [
11
+ 'config', '--global',
12
+ `credential.${origin}.helper`,
13
+ '!runwork git-credential-helper',
14
+ ], { stdio: 'pipe' });
15
+ }
16
+ catch (err) {
17
+ const code = err?.code;
18
+ if (code === 'ENOENT') {
19
+ console.warn('Note: git is not installed. Skipping git credential helper setup.');
20
+ console.warn('Install git before running `runwork init`, `clone`, `dev`, or `deploy`.');
21
+ return;
22
+ }
23
+ throw err;
24
+ }
14
25
  }
15
26
  /**
16
27
  * Remove the git credential helper configuration for our remote.
@@ -2,22 +2,26 @@ import { createHash } from 'crypto';
2
2
  import { execFileSync } from 'child_process';
3
3
  import { readFileSync, writeFileSync, existsSync, readdirSync, mkdirSync } from 'fs';
4
4
  import { join, relative } from 'path';
5
- const SKIP_DIRS = new Set(['.git', 'node_modules', '.runwork']);
6
- const SKIP_FILES = new Set(['.dev.vars', '.env']);
5
+ import { buildIgnoreSets } from '../utils/ignore-matcher.js';
7
6
  function sha256(data) {
8
7
  return 'sha256:' + createHash('sha256').update(data).digest('hex');
9
8
  }
10
- function walkDir(dir, base) {
9
+ function walkDir(dir, base, sets) {
11
10
  const results = [];
12
11
  const entries = readdirSync(dir, { withFileTypes: true });
13
12
  for (const entry of entries) {
14
- if (SKIP_DIRS.has(entry.name))
13
+ if (sets.dirs.has(entry.name))
15
14
  continue;
16
- if (SKIP_FILES.has(entry.name))
15
+ if (sets.files.has(entry.name))
17
16
  continue;
17
+ if (entry.isFile()) {
18
+ const dotIndex = entry.name.lastIndexOf('.');
19
+ if (dotIndex >= 0 && sets.extensions.has(entry.name.slice(dotIndex)))
20
+ continue;
21
+ }
18
22
  const fullPath = join(dir, entry.name);
19
23
  if (entry.isDirectory()) {
20
- results.push(...walkDir(fullPath, base));
24
+ results.push(...walkDir(fullPath, base, sets));
21
25
  }
22
26
  else if (entry.isFile()) {
23
27
  results.push(relative(base, fullPath));
@@ -27,7 +31,8 @@ function walkDir(dir, base) {
27
31
  }
28
32
  export async function generateManifest(dir) {
29
33
  const files = {};
30
- const allFiles = walkDir(dir, dir);
34
+ const sets = buildIgnoreSets(dir);
35
+ const allFiles = walkDir(dir, dir, sets);
31
36
  for (const relPath of allFiles) {
32
37
  const content = readFileSync(join(dir, relPath));
33
38
  files[relPath] = sha256(content);
@@ -104,9 +109,27 @@ export async function detectUserEdits(dir, manifest) {
104
109
  // 3. Scan manifest files on disk directly — catches gitignored files that
105
110
  // git ls-files --exclude-standard would miss. If a template file was added
106
111
  // to .gitignore by the user and then modified, only this check detects it.
112
+ //
113
+ // Tracked files are excluded here because their state is fully described
114
+ // by Method 1 (`git diff HEAD`). After `runwork clone`, AI-customized
115
+ // versions of template files are committed at HEAD and would otherwise be
116
+ // false-flagged on every fresh clone.
117
+ const trackedFiles = new Set();
118
+ try {
119
+ const tracked = execFileSync('git', ['-c', 'core.quotePath=false', 'ls-files'], { cwd: dir, encoding: 'utf-8' }).trim();
120
+ for (const relPath of tracked.split('\n')) {
121
+ if (relPath)
122
+ trackedFiles.add(relPath);
123
+ }
124
+ }
125
+ catch {
126
+ // No git repo — every manifest file remains a candidate for Method 3.
127
+ }
107
128
  for (const [relPath, expectedHash] of Object.entries(manifest.files)) {
108
129
  if (edits.includes(relPath))
109
130
  continue;
131
+ if (trackedFiles.has(relPath))
132
+ continue;
110
133
  const filePath = join(dir, relPath);
111
134
  try {
112
135
  const content = readFileSync(filePath);
@@ -0,0 +1,47 @@
1
+ /**
2
+ * Shared types for everything Runwork can detect and offer to install
3
+ * locally. Both AI agents and developer prerequisites (git, etc.) extend
4
+ * `InstallableTool` so the desktop can render and detect them uniformly.
5
+ *
6
+ * Browser-safe: no node-only imports.
7
+ */
8
+ /** Platform-specific string. Plain string = same on all platforms. */
9
+ export type PlatformString = string | {
10
+ default?: string;
11
+ macos?: string;
12
+ windows?: string;
13
+ linux?: string;
14
+ };
15
+ /**
16
+ * Detection strategy. Supports simple binary/path checks plus richer
17
+ * Windows-specific methods and a nested `any` combinator for tools that
18
+ * ship as both GUI app and CLI binary.
19
+ */
20
+ export type Detection = {
21
+ method: 'binary' | 'path';
22
+ target: PlatformString;
23
+ } | {
24
+ method: 'windows-appx' | 'windows-start-app';
25
+ target: string | string[];
26
+ } | {
27
+ method: 'any';
28
+ target: Detection[];
29
+ };
30
+ /**
31
+ * Common shape for anything that can be detected and installed locally.
32
+ * AI agents and developer tools both extend this.
33
+ */
34
+ export interface InstallableTool {
35
+ /** Stable identifier, kebab-case (e.g. "claude-code", "git"). */
36
+ slug: string;
37
+ /** Display name. */
38
+ name: string;
39
+ /** One-line description shown in lists. */
40
+ description: string;
41
+ /** How to determine whether this tool is installed locally. */
42
+ detection: Detection;
43
+ /** Public install/download URL — universal fallback when auto-install isn't available. */
44
+ downloadUrl?: string;
45
+ /** Logo identifier (matches an entry in the desktop's logo map). Optional. */
46
+ logo?: string;
47
+ }
@@ -0,0 +1,8 @@
1
+ /**
2
+ * Shared types for everything Runwork can detect and offer to install
3
+ * locally. Both AI agents and developer prerequisites (git, etc.) extend
4
+ * `InstallableTool` so the desktop can render and detect them uniformly.
5
+ *
6
+ * Browser-safe: no node-only imports.
7
+ */
8
+ export {};
package/dist/types.d.ts CHANGED
@@ -141,6 +141,12 @@ export interface SetupState {
141
141
  }>;
142
142
  reportedAgentSlugs?: string[];
143
143
  lastHealthReportAt?: string;
144
+ /**
145
+ * ISO timestamp of the last agent-installation detection pass. Sync uses
146
+ * this with a TTL to skip the (Windows-expensive) PowerShell probe waterfall
147
+ * on every cycle while still catching newly-installed agents within a day.
148
+ */
149
+ lastDetectedAt?: string;
144
150
  }
145
151
  export interface WorkflowInfo {
146
152
  name: string;
@@ -0,0 +1 @@
1
+ export {};