@cencivic/runx 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +147 -0
- package/bin/runx.js +2 -0
- package/dist/cli.d.ts +1 -0
- package/dist/cli.js +92 -0
- package/dist/parser.d.ts +16 -0
- package/dist/parser.js +77 -0
- package/dist/resolver.d.ts +22 -0
- package/dist/resolver.js +121 -0
- package/dist/runner.d.ts +4 -0
- package/dist/runner.js +29 -0
- package/dist/types.d.ts +3 -0
- package/dist/types.js +1 -0
- package/dist/update-check.d.ts +9 -0
- package/dist/update-check.js +101 -0
- package/package.json +35 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 cencivic
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
# @cencivic/runx
|
|
2
|
+
|
|
3
|
+
[](https://www.npmjs.com/package/@cencivic/runx)
|
|
4
|
+
[](https://www.npmjs.com/package/@cencivic/runx)
|
|
5
|
+
[](https://opensource.org/licenses/MIT)
|
|
6
|
+
|
|
7
|
+
Run TypeScript scripts with inline dependencies.
|
|
8
|
+
|
|
9
|
+
Inspired by [uv](https://github.com/astral-sh/uv)'s script runner for Python.
|
|
10
|
+
|
|
11
|
+
## Table of Contents
|
|
12
|
+
|
|
13
|
+
- [Why runx?](#why-runx)
|
|
14
|
+
- [Installation](#installation)
|
|
15
|
+
- [Usage](#usage)
|
|
16
|
+
- [CLI](#cli)
|
|
17
|
+
- [Contributing](#contributing)
|
|
18
|
+
- [Acknowledgments](#acknowledgments)
|
|
19
|
+
- [License](#license)
|
|
20
|
+
|
|
21
|
+
## Why runx?
|
|
22
|
+
|
|
23
|
+
### AI-friendly scripting without side effects
|
|
24
|
+
|
|
25
|
+
AI coding assistants (Claude, Copilot, Cursor, etc.) are great at generating one-off utility scripts — data migration, file processing, API testing, quick prototyping, and more. But these scripts often need external packages, and that's where things get messy:
|
|
26
|
+
|
|
27
|
+
- Adding dependencies to the project's `package.json` just for a throwaway script
|
|
28
|
+
- Lock file (`package-lock.json`, `bun.lock`) diffs polluting your git history
|
|
29
|
+
- Risk of version conflicts with your actual project dependencies
|
|
30
|
+
- Forgetting to clean up after the script is no longer needed
|
|
31
|
+
|
|
32
|
+
**runx solves this.** Each script declares its own dependencies inline, installed into an isolated cache — completely independent from your project.
|
|
33
|
+
|
|
34
|
+
```typescript
|
|
35
|
+
#!/usr/bin/env runx
|
|
36
|
+
/**
|
|
37
|
+
* @runx {
|
|
38
|
+
* "dependencies": {
|
|
39
|
+
* "csv-parse": "^5.5.0",
|
|
40
|
+
* "chalk": "^5.3.0"
|
|
41
|
+
* }
|
|
42
|
+
* }
|
|
43
|
+
*/
|
|
44
|
+
|
|
45
|
+
import { parse } from 'csv-parse/sync';
|
|
46
|
+
import chalk from 'chalk';
|
|
47
|
+
|
|
48
|
+
// AI-generated one-off migration script
|
|
49
|
+
// Zero impact on your project's package.json or lock file
|
|
50
|
+
const data = parse(await Bun.file('data.csv').text(), { columns: true });
|
|
51
|
+
console.log(chalk.green(`Processed ${data.length} rows`));
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
Just ask your AI assistant to *"write a runx script that does X"* — you get a single, self-contained `.ts` file that runs anywhere without touching your project configuration. When you're done, simply delete the file. No cleanup needed.
|
|
55
|
+
|
|
56
|
+
## Installation
|
|
57
|
+
|
|
58
|
+
```bash
|
|
59
|
+
npm install -g @cencivic/runx
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
Requires [bun](https://bun.sh) to be installed.
|
|
63
|
+
|
|
64
|
+
## Usage
|
|
65
|
+
|
|
66
|
+
Create a TypeScript file with dependencies declared in a `@runx` JSDoc block:
|
|
67
|
+
|
|
68
|
+
```typescript
|
|
69
|
+
#!/usr/bin/env runx
|
|
70
|
+
/**
|
|
71
|
+
* @runx {
|
|
72
|
+
* "dependencies": {
|
|
73
|
+
* "zod": "^3.22.0",
|
|
74
|
+
* "chalk": "^5.3.0"
|
|
75
|
+
* }
|
|
76
|
+
* }
|
|
77
|
+
*/
|
|
78
|
+
|
|
79
|
+
import { z } from 'zod';
|
|
80
|
+
import chalk from 'chalk';
|
|
81
|
+
|
|
82
|
+
const UserSchema = z.object({
|
|
83
|
+
name: z.string(),
|
|
84
|
+
age: z.number(),
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
const user = UserSchema.parse({ name: 'Alice', age: 30 });
|
|
88
|
+
console.log(chalk.green(`Hello, ${user.name}!`));
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
Run it:
|
|
92
|
+
|
|
93
|
+
```bash
|
|
94
|
+
runx script.ts
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
On first run, dependencies are installed and cached. Subsequent runs use the cache.
|
|
98
|
+
|
|
99
|
+
### Metadata Fields
|
|
100
|
+
|
|
101
|
+
The `@runx` block accepts a JSON object with the following fields:
|
|
102
|
+
|
|
103
|
+
| Field | Type | Description |
|
|
104
|
+
|---|---|---|
|
|
105
|
+
| `dependencies` | `Record<string, string>` | Package.json-style dependencies (required) |
|
|
106
|
+
| `env` | `Record<string, string>` | Environment variables to set |
|
|
107
|
+
| `engines` | `{ bun?: string; node?: string }` | Runtime version requirements |
|
|
108
|
+
| `args` | `string[]` | Default arguments for the script |
|
|
109
|
+
|
|
110
|
+
Full example with all fields:
|
|
111
|
+
|
|
112
|
+
```typescript
|
|
113
|
+
/**
|
|
114
|
+
* @runx {
|
|
115
|
+
* "dependencies": {
|
|
116
|
+
* "chalk": "^5.0.0",
|
|
117
|
+
* "zod": "~3.22.0",
|
|
118
|
+
* "@types/node": "20.0.0"
|
|
119
|
+
* },
|
|
120
|
+
* "env": { "NODE_ENV": "production", "DEBUG": "true" },
|
|
121
|
+
* "engines": { "bun": ">=1.0", "node": ">=18" },
|
|
122
|
+
* "args": ["--verbose"]
|
|
123
|
+
* }
|
|
124
|
+
*/
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
## CLI
|
|
128
|
+
|
|
129
|
+
```bash
|
|
130
|
+
runx <script.ts> [args...] # Run a script
|
|
131
|
+
runx --clean # Clear all cached environments
|
|
132
|
+
runx --version # Show version
|
|
133
|
+
runx --help # Show help
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
## Contributing
|
|
137
|
+
|
|
138
|
+
Contributions are welcome! Please see [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines.
|
|
139
|
+
|
|
140
|
+
## Acknowledgments
|
|
141
|
+
|
|
142
|
+
- [uv](https://github.com/astral-sh/uv) - The inspiration for this project. uv's inline script dependencies for Python showed how powerful this pattern can be.
|
|
143
|
+
- [bun](https://bun.sh) - Used for fast dependency installation and script execution.
|
|
144
|
+
|
|
145
|
+
## License
|
|
146
|
+
|
|
147
|
+
MIT
|
package/bin/runx.js
ADDED
package/dist/cli.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/dist/cli.js
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { execFileSync } from 'node:child_process';
|
|
2
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
3
|
+
import { dirname, join } from 'node:path';
|
|
4
|
+
import { fileURLToPath } from 'node:url';
|
|
5
|
+
import { parseScriptMetadata } from './parser.js';
|
|
6
|
+
import { cleanCache, resolveEnvironment } from './resolver.js';
|
|
7
|
+
import { runScript } from './runner.js';
|
|
8
|
+
import { checkForUpdate, formatUpdateMessage } from './update-check.js';
|
|
9
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
10
|
+
const { version: VERSION } = JSON.parse(readFileSync(join(__dirname, '..', 'package.json'), 'utf-8'));
|
|
11
|
+
const HELP = `
|
|
12
|
+
runx - uv-like script runner for TypeScript
|
|
13
|
+
|
|
14
|
+
Usage:
|
|
15
|
+
runx <script.ts> [args...] Run a TypeScript script
|
|
16
|
+
runx --clean Clean all cached environments
|
|
17
|
+
runx --version Show version
|
|
18
|
+
runx --help Show this help
|
|
19
|
+
|
|
20
|
+
Example script:
|
|
21
|
+
/**
|
|
22
|
+
* @runx {
|
|
23
|
+
* "dependencies": {
|
|
24
|
+
* "chalk": "^5.0.0",
|
|
25
|
+
* "zod": "~3.22.0"
|
|
26
|
+
* }
|
|
27
|
+
* }
|
|
28
|
+
*/
|
|
29
|
+
import chalk from 'chalk';
|
|
30
|
+
console.log(chalk.green('Hello!'));
|
|
31
|
+
|
|
32
|
+
Dependency versions (same as package.json):
|
|
33
|
+
"5.3.0" Exact version
|
|
34
|
+
"^5.0.0" Compatible with 5.x.x (minor/patch updates)
|
|
35
|
+
"~5.3.0" Approximately 5.3.x (patch updates only)
|
|
36
|
+
">=1.0.0" Version range
|
|
37
|
+
"latest" Latest version
|
|
38
|
+
"*" Any version
|
|
39
|
+
`.trim();
|
|
40
|
+
function ensureBun() {
|
|
41
|
+
try {
|
|
42
|
+
execFileSync('bun', ['--version'], { stdio: 'ignore' });
|
|
43
|
+
}
|
|
44
|
+
catch {
|
|
45
|
+
console.error('Error: bun is required but not found.');
|
|
46
|
+
console.error('Install it: https://bun.sh');
|
|
47
|
+
process.exit(1);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
async function main() {
|
|
51
|
+
const args = process.argv.slice(2);
|
|
52
|
+
if (args.length === 0 || args[0] === '--help' || args[0] === '-h') {
|
|
53
|
+
console.log(HELP);
|
|
54
|
+
process.exit(0);
|
|
55
|
+
}
|
|
56
|
+
if (args[0] === '--version' || args[0] === '-v') {
|
|
57
|
+
console.log(VERSION);
|
|
58
|
+
process.exit(0);
|
|
59
|
+
}
|
|
60
|
+
if (args[0] === '--clean') {
|
|
61
|
+
await cleanCache();
|
|
62
|
+
process.exit(0);
|
|
63
|
+
}
|
|
64
|
+
ensureBun();
|
|
65
|
+
const scriptPath = args[0];
|
|
66
|
+
const scriptArgs = args.slice(1);
|
|
67
|
+
if (!existsSync(scriptPath)) {
|
|
68
|
+
console.error(`Error: File not found: ${scriptPath}`);
|
|
69
|
+
process.exit(1);
|
|
70
|
+
}
|
|
71
|
+
// Start update check in background (non-blocking)
|
|
72
|
+
const updatePromise = checkForUpdate(VERSION);
|
|
73
|
+
try {
|
|
74
|
+
// Parse script metadata
|
|
75
|
+
const metadata = await parseScriptMetadata(scriptPath);
|
|
76
|
+
// Resolve environment (install deps if needed)
|
|
77
|
+
const nodeModulesPath = await resolveEnvironment(metadata, scriptPath);
|
|
78
|
+
// Run the script
|
|
79
|
+
const exitCode = await runScript(scriptPath, nodeModulesPath, scriptArgs);
|
|
80
|
+
// Show update notification after script finishes
|
|
81
|
+
const latestVersion = await updatePromise;
|
|
82
|
+
if (latestVersion) {
|
|
83
|
+
console.error(formatUpdateMessage(VERSION, latestVersion));
|
|
84
|
+
}
|
|
85
|
+
process.exit(exitCode);
|
|
86
|
+
}
|
|
87
|
+
catch (err) {
|
|
88
|
+
console.error('Error:', err instanceof Error ? err.message : err);
|
|
89
|
+
process.exit(1);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
main();
|
package/dist/parser.d.ts
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { ScriptMetadata } from './types.js';
|
|
2
|
+
/**
|
|
3
|
+
* Parse script metadata from @runx JSDoc tag.
|
|
4
|
+
*
|
|
5
|
+
* Expected format:
|
|
6
|
+
* /**
|
|
7
|
+
* * @runx {
|
|
8
|
+
* * "dependencies": {
|
|
9
|
+
* * "zod": "^3.22.0",
|
|
10
|
+
* * "chalk": "^5.3.0"
|
|
11
|
+
* * }
|
|
12
|
+
* * }
|
|
13
|
+
* *\/
|
|
14
|
+
*/
|
|
15
|
+
export declare function parseScriptMetadata(scriptPath: string): Promise<ScriptMetadata>;
|
|
16
|
+
export declare function parseMetadataFromContent(content: string): ScriptMetadata;
|
package/dist/parser.js
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { readFile } from 'node:fs/promises';
|
|
2
|
+
import { parse } from 'comment-parser';
|
|
3
|
+
/**
|
|
4
|
+
* Parse script metadata from @runx JSDoc tag.
|
|
5
|
+
*
|
|
6
|
+
* Expected format:
|
|
7
|
+
* /**
|
|
8
|
+
* * @runx {
|
|
9
|
+
* * "dependencies": {
|
|
10
|
+
* * "zod": "^3.22.0",
|
|
11
|
+
* * "chalk": "^5.3.0"
|
|
12
|
+
* * }
|
|
13
|
+
* * }
|
|
14
|
+
* *\/
|
|
15
|
+
*/
|
|
16
|
+
export async function parseScriptMetadata(scriptPath) {
|
|
17
|
+
const content = await readFile(scriptPath, 'utf-8');
|
|
18
|
+
return parseMetadataFromContent(content);
|
|
19
|
+
}
|
|
20
|
+
export function parseMetadataFromContent(content) {
|
|
21
|
+
const parsed = parse(content);
|
|
22
|
+
for (const block of parsed) {
|
|
23
|
+
for (const tag of block.tags) {
|
|
24
|
+
if (tag.tag === 'runx') {
|
|
25
|
+
const jsonStr = extractJsonFromTag(tag);
|
|
26
|
+
if (jsonStr) {
|
|
27
|
+
try {
|
|
28
|
+
const metadata = JSON.parse(jsonStr);
|
|
29
|
+
return {
|
|
30
|
+
dependencies: metadata.dependencies ?? {},
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
catch {
|
|
34
|
+
throw new Error(`Invalid JSON in @runx tag: ${jsonStr}`);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
return { dependencies: {} };
|
|
41
|
+
}
|
|
42
|
+
function extractJsonFromTag(tag) {
|
|
43
|
+
// comment-parser puts { ... } content in the 'type' token
|
|
44
|
+
// For multiline JSON, we need to reconstruct from all source lines
|
|
45
|
+
const typeParts = [];
|
|
46
|
+
for (const sourceLine of tag.source) {
|
|
47
|
+
const typeToken = sourceLine.tokens.type;
|
|
48
|
+
if (typeToken) {
|
|
49
|
+
typeParts.push(typeToken);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
const fullContent = typeParts.join('\n').trim();
|
|
53
|
+
// Find the JSON object boundaries
|
|
54
|
+
const startIndex = fullContent.indexOf('{');
|
|
55
|
+
if (startIndex === -1) {
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
58
|
+
// Find matching closing brace
|
|
59
|
+
let braceCount = 0;
|
|
60
|
+
let endIndex = -1;
|
|
61
|
+
for (let i = startIndex; i < fullContent.length; i++) {
|
|
62
|
+
if (fullContent[i] === '{') {
|
|
63
|
+
braceCount++;
|
|
64
|
+
}
|
|
65
|
+
else if (fullContent[i] === '}') {
|
|
66
|
+
braceCount--;
|
|
67
|
+
if (braceCount === 0) {
|
|
68
|
+
endIndex = i;
|
|
69
|
+
break;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
if (endIndex === -1) {
|
|
74
|
+
return null;
|
|
75
|
+
}
|
|
76
|
+
return fullContent.slice(startIndex, endIndex + 1);
|
|
77
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { ScriptMetadata } from './types.js';
|
|
2
|
+
/**
|
|
3
|
+
* Generate cache key from dependencies.
|
|
4
|
+
*/
|
|
5
|
+
export declare function generateCacheKey(metadata: ScriptMetadata): string;
|
|
6
|
+
/**
|
|
7
|
+
* Get cache directory path for given metadata and script path.
|
|
8
|
+
*/
|
|
9
|
+
export declare function getCacheDir(metadata: ScriptMetadata, scriptPath: string): string;
|
|
10
|
+
/**
|
|
11
|
+
* Check if cache exists for given metadata.
|
|
12
|
+
*/
|
|
13
|
+
export declare function cacheExists(metadata: ScriptMetadata, scriptPath: string): boolean;
|
|
14
|
+
/**
|
|
15
|
+
* Resolve environment for script metadata.
|
|
16
|
+
* Creates cache if it doesn't exist.
|
|
17
|
+
*/
|
|
18
|
+
export declare function resolveEnvironment(metadata: ScriptMetadata, scriptPath: string): Promise<string>;
|
|
19
|
+
/**
|
|
20
|
+
* Clean all cached environments.
|
|
21
|
+
*/
|
|
22
|
+
export declare function cleanCache(): Promise<void>;
|
package/dist/resolver.js
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import { createHash } from 'node:crypto';
|
|
3
|
+
import { existsSync } from 'node:fs';
|
|
4
|
+
import { mkdir, readdir, rm, writeFile } from 'node:fs/promises';
|
|
5
|
+
import { homedir } from 'node:os';
|
|
6
|
+
import { basename, join } from 'node:path';
|
|
7
|
+
/**
|
|
8
|
+
* Get platform-appropriate cache base directory.
|
|
9
|
+
*/
|
|
10
|
+
function getCacheBase() {
|
|
11
|
+
if (process.platform === 'win32') {
|
|
12
|
+
const localAppData = process.env.LOCALAPPDATA;
|
|
13
|
+
if (localAppData) {
|
|
14
|
+
return join(localAppData, 'runx', 'envs');
|
|
15
|
+
}
|
|
16
|
+
return join(homedir(), 'AppData', 'Local', 'runx', 'envs');
|
|
17
|
+
}
|
|
18
|
+
// macOS & Linux: XDG_CACHE_HOME or ~/.cache
|
|
19
|
+
const xdgCacheHome = process.env.XDG_CACHE_HOME;
|
|
20
|
+
if (xdgCacheHome) {
|
|
21
|
+
return join(xdgCacheHome, 'runx', 'envs');
|
|
22
|
+
}
|
|
23
|
+
return join(homedir(), '.cache', 'runx', 'envs');
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Generate cache key from dependencies.
|
|
27
|
+
*/
|
|
28
|
+
export function generateCacheKey(metadata) {
|
|
29
|
+
const sortedEntries = Object.entries(metadata.dependencies).sort(([a], [b]) => a.localeCompare(b));
|
|
30
|
+
const content = JSON.stringify(sortedEntries);
|
|
31
|
+
return createHash('sha256').update(content).digest('hex').slice(0, 16);
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Extract script name (without extension) from script path.
|
|
35
|
+
*/
|
|
36
|
+
function getScriptName(scriptPath) {
|
|
37
|
+
const base = basename(scriptPath);
|
|
38
|
+
const dotIndex = base.lastIndexOf('.');
|
|
39
|
+
return dotIndex > 0 ? base.slice(0, dotIndex) : base;
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Get cache directory path for given metadata and script path.
|
|
43
|
+
*/
|
|
44
|
+
export function getCacheDir(metadata, scriptPath) {
|
|
45
|
+
const key = generateCacheKey(metadata);
|
|
46
|
+
const scriptName = getScriptName(scriptPath);
|
|
47
|
+
return join(getCacheBase(), `${scriptName}-${key}`);
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Check if cache exists for given metadata.
|
|
51
|
+
*/
|
|
52
|
+
export function cacheExists(metadata, scriptPath) {
|
|
53
|
+
const cacheDir = getCacheDir(metadata, scriptPath);
|
|
54
|
+
return existsSync(join(cacheDir, 'node_modules'));
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* Create package.json content for dependencies.
|
|
58
|
+
*/
|
|
59
|
+
function createPackageJson(metadata) {
|
|
60
|
+
return JSON.stringify({
|
|
61
|
+
name: 'runx-env',
|
|
62
|
+
version: '0.0.0',
|
|
63
|
+
private: true,
|
|
64
|
+
dependencies: metadata.dependencies,
|
|
65
|
+
}, null, 2);
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* Run bun install in the cache directory.
|
|
69
|
+
*/
|
|
70
|
+
async function runBunInstall(cacheDir) {
|
|
71
|
+
return new Promise((resolve, reject) => {
|
|
72
|
+
const proc = spawn('bun', ['install'], {
|
|
73
|
+
cwd: cacheDir,
|
|
74
|
+
stdio: 'inherit',
|
|
75
|
+
});
|
|
76
|
+
proc.on('close', (code) => {
|
|
77
|
+
if (code === 0) {
|
|
78
|
+
resolve();
|
|
79
|
+
}
|
|
80
|
+
else {
|
|
81
|
+
reject(new Error(`bun install failed with code ${code}`));
|
|
82
|
+
}
|
|
83
|
+
});
|
|
84
|
+
proc.on('error', reject);
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* Resolve environment for script metadata.
|
|
89
|
+
* Creates cache if it doesn't exist.
|
|
90
|
+
*/
|
|
91
|
+
export async function resolveEnvironment(metadata, scriptPath) {
|
|
92
|
+
if (Object.keys(metadata.dependencies).length === 0) {
|
|
93
|
+
return '';
|
|
94
|
+
}
|
|
95
|
+
const cacheDir = getCacheDir(metadata, scriptPath);
|
|
96
|
+
if (cacheExists(metadata, scriptPath)) {
|
|
97
|
+
return join(cacheDir, 'node_modules');
|
|
98
|
+
}
|
|
99
|
+
// Create cache directory
|
|
100
|
+
await mkdir(cacheDir, { recursive: true });
|
|
101
|
+
// Write package.json
|
|
102
|
+
const packageJson = createPackageJson(metadata);
|
|
103
|
+
await writeFile(join(cacheDir, 'package.json'), packageJson);
|
|
104
|
+
// Run bun install
|
|
105
|
+
console.error(`Installing dependencies...`);
|
|
106
|
+
await runBunInstall(cacheDir);
|
|
107
|
+
return join(cacheDir, 'node_modules');
|
|
108
|
+
}
|
|
109
|
+
/**
|
|
110
|
+
* Clean all cached environments.
|
|
111
|
+
*/
|
|
112
|
+
export async function cleanCache() {
|
|
113
|
+
const cacheBase = getCacheBase();
|
|
114
|
+
if (!existsSync(cacheBase)) {
|
|
115
|
+
console.log('Cache is already empty.');
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
const entries = await readdir(cacheBase);
|
|
119
|
+
await rm(cacheBase, { recursive: true, force: true });
|
|
120
|
+
console.log(`Cleaned ${entries.length} cached environment(s).`);
|
|
121
|
+
}
|
package/dist/runner.d.ts
ADDED
package/dist/runner.js
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import { resolve } from 'node:path';
|
|
3
|
+
/**
|
|
4
|
+
* Run a TypeScript script with bun.
|
|
5
|
+
*/
|
|
6
|
+
export async function runScript(scriptPath, nodeModulesPath, args) {
|
|
7
|
+
const absoluteScriptPath = resolve(scriptPath);
|
|
8
|
+
const env = { ...process.env };
|
|
9
|
+
if (nodeModulesPath) {
|
|
10
|
+
// Set NODE_PATH to include the cached node_modules
|
|
11
|
+
const existingNodePath = env.NODE_PATH || '';
|
|
12
|
+
const separator = process.platform === 'win32' ? ';' : ':';
|
|
13
|
+
env.NODE_PATH = existingNodePath
|
|
14
|
+
? `${nodeModulesPath}${separator}${existingNodePath}`
|
|
15
|
+
: nodeModulesPath;
|
|
16
|
+
}
|
|
17
|
+
return new Promise((resolve, reject) => {
|
|
18
|
+
const proc = spawn('bun', ['run', absoluteScriptPath, ...args], {
|
|
19
|
+
stdio: 'inherit',
|
|
20
|
+
env,
|
|
21
|
+
});
|
|
22
|
+
proc.on('close', (code) => {
|
|
23
|
+
resolve(code ?? 0);
|
|
24
|
+
});
|
|
25
|
+
proc.on('error', (err) => {
|
|
26
|
+
reject(err);
|
|
27
|
+
});
|
|
28
|
+
});
|
|
29
|
+
}
|
package/dist/types.d.ts
ADDED
package/dist/types.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Check npm registry for a newer version of runx.
|
|
3
|
+
* Caches result for 24 hours. Returns silently on any error.
|
|
4
|
+
*/
|
|
5
|
+
export declare function checkForUpdate(currentVersion: string): Promise<string>;
|
|
6
|
+
/**
|
|
7
|
+
* Format update notification message.
|
|
8
|
+
*/
|
|
9
|
+
export declare function formatUpdateMessage(currentVersion: string, latestVersion: string): string;
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs';
|
|
2
|
+
import { mkdir, readFile, writeFile } from 'node:fs/promises';
|
|
3
|
+
import { homedir } from 'node:os';
|
|
4
|
+
import { dirname, join } from 'node:path';
|
|
5
|
+
const REGISTRY_URL = 'https://registry.npmjs.org/@cencivic/runx/latest';
|
|
6
|
+
const CHECK_INTERVAL = 86_400_000; // 24 hours
|
|
7
|
+
/**
|
|
8
|
+
* Get path for the update check cache file.
|
|
9
|
+
*/
|
|
10
|
+
function getCacheFile() {
|
|
11
|
+
if (process.platform === 'win32') {
|
|
12
|
+
const localAppData = process.env.LOCALAPPDATA;
|
|
13
|
+
if (localAppData) {
|
|
14
|
+
return join(localAppData, 'runx', 'update-check.json');
|
|
15
|
+
}
|
|
16
|
+
return join(homedir(), 'AppData', 'Local', 'runx', 'update-check.json');
|
|
17
|
+
}
|
|
18
|
+
const xdgCacheHome = process.env.XDG_CACHE_HOME;
|
|
19
|
+
if (xdgCacheHome) {
|
|
20
|
+
return join(xdgCacheHome, 'runx', 'update-check.json');
|
|
21
|
+
}
|
|
22
|
+
return join(homedir(), '.cache', 'runx', 'update-check.json');
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Read cached update check result.
|
|
26
|
+
*/
|
|
27
|
+
async function readCache() {
|
|
28
|
+
try {
|
|
29
|
+
const cacheFile = getCacheFile();
|
|
30
|
+
if (!existsSync(cacheFile))
|
|
31
|
+
return null;
|
|
32
|
+
const data = await readFile(cacheFile, 'utf-8');
|
|
33
|
+
return JSON.parse(data);
|
|
34
|
+
}
|
|
35
|
+
catch {
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Write update check result to cache.
|
|
41
|
+
*/
|
|
42
|
+
async function writeCache(result) {
|
|
43
|
+
const cacheFile = getCacheFile();
|
|
44
|
+
await mkdir(dirname(cacheFile), { recursive: true });
|
|
45
|
+
await writeFile(cacheFile, JSON.stringify(result));
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* Compare two semver version strings.
|
|
49
|
+
* Returns true if `latest` is newer than `current`.
|
|
50
|
+
*/
|
|
51
|
+
function isNewer(current, latest) {
|
|
52
|
+
const a = current.split('.').map(Number);
|
|
53
|
+
const b = latest.split('.').map(Number);
|
|
54
|
+
for (let i = 0; i < 3; i++) {
|
|
55
|
+
if ((b[i] ?? 0) > (a[i] ?? 0))
|
|
56
|
+
return true;
|
|
57
|
+
if ((b[i] ?? 0) < (a[i] ?? 0))
|
|
58
|
+
return false;
|
|
59
|
+
}
|
|
60
|
+
return false;
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Check npm registry for a newer version of runx.
|
|
64
|
+
* Caches result for 24 hours. Returns silently on any error.
|
|
65
|
+
*/
|
|
66
|
+
export async function checkForUpdate(currentVersion) {
|
|
67
|
+
// Skip in CI
|
|
68
|
+
if (process.env.CI)
|
|
69
|
+
return '';
|
|
70
|
+
try {
|
|
71
|
+
const cached = await readCache();
|
|
72
|
+
const now = Date.now();
|
|
73
|
+
// Use cache if within 24h
|
|
74
|
+
if (cached && now - cached.time < CHECK_INTERVAL) {
|
|
75
|
+
return isNewer(currentVersion, cached.latest) ? cached.latest : '';
|
|
76
|
+
}
|
|
77
|
+
// Fetch with 1.5s timeout
|
|
78
|
+
const response = await Promise.race([
|
|
79
|
+
fetch(REGISTRY_URL),
|
|
80
|
+
new Promise((_, reject) => setTimeout(() => reject(new Error('timeout')), 1500)),
|
|
81
|
+
]);
|
|
82
|
+
const data = (await response.json());
|
|
83
|
+
const latest = data.version;
|
|
84
|
+
await writeCache({ latest, time: now });
|
|
85
|
+
return isNewer(currentVersion, latest) ? latest : '';
|
|
86
|
+
}
|
|
87
|
+
catch {
|
|
88
|
+
return '';
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* Format update notification message.
|
|
93
|
+
*/
|
|
94
|
+
export function formatUpdateMessage(currentVersion, latestVersion) {
|
|
95
|
+
return [
|
|
96
|
+
'',
|
|
97
|
+
` Update available: ${currentVersion} → \x1b[32m${latestVersion}\x1b[0m`,
|
|
98
|
+
` Run \x1b[36mbun install -g @cencivic/runx\x1b[0m to update`,
|
|
99
|
+
'',
|
|
100
|
+
].join('\n');
|
|
101
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@cencivic/runx",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "uv-like script runner for TypeScript",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"bin": {
|
|
8
|
+
"runx": "./bin/runx.js"
|
|
9
|
+
},
|
|
10
|
+
"files": [
|
|
11
|
+
"bin",
|
|
12
|
+
"dist"
|
|
13
|
+
],
|
|
14
|
+
"scripts": {
|
|
15
|
+
"build": "tsc",
|
|
16
|
+
"test": "bun test",
|
|
17
|
+
"lint": "biome check --fix .",
|
|
18
|
+
"format": "biome format --write .",
|
|
19
|
+
"check": "biome check .",
|
|
20
|
+
"prepublishOnly": "npm run build"
|
|
21
|
+
},
|
|
22
|
+
"engines": {
|
|
23
|
+
"node": ">=18"
|
|
24
|
+
},
|
|
25
|
+
"devDependencies": {
|
|
26
|
+
"@biomejs/biome": "^2.3.13",
|
|
27
|
+
"@types/node": "^20.0.0",
|
|
28
|
+
"bun-types": "^1.3.8",
|
|
29
|
+
"typescript": "^5.0.0",
|
|
30
|
+
"@types/bun": "latest"
|
|
31
|
+
},
|
|
32
|
+
"dependencies": {
|
|
33
|
+
"comment-parser": "^1.4.5"
|
|
34
|
+
}
|
|
35
|
+
}
|