@orxataguy/tyr 1.0.40 → 1.0.41
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/README.md +47 -0
- package/package.json +1 -1
- package/src/core/Kernel.ts +44 -8
- package/src/core/sys/help.ts +4 -1
- package/src/core/sys/modules.ts +414 -0
package/README.md
CHANGED
|
@@ -173,6 +173,53 @@ tyr doc
|
|
|
173
173
|
# → Open http://localhost:3000
|
|
174
174
|
```
|
|
175
175
|
|
|
176
|
+
### Imported modules
|
|
177
|
+
|
|
178
|
+
Third parties can distribute commands as a `manifest.json` — a flat JSON map of command name to the raw URL of a `.tyr.ts` file:
|
|
179
|
+
|
|
180
|
+
```json
|
|
181
|
+
{
|
|
182
|
+
"deploy": "https://raw.githubusercontent.com/someuser/tyr-modules/main/deploy.tyr.ts",
|
|
183
|
+
"rollback": "https://raw.githubusercontent.com/someuser/tyr-modules/main/rollback.tyr.ts"
|
|
184
|
+
}
|
|
185
|
+
```
|
|
186
|
+
|
|
187
|
+
Register one with `tyr --add`, which validates the manifest, records it in `~/.tyr/imported_modules.yml`, and immediately downloads whatever commands are missing:
|
|
188
|
+
|
|
189
|
+
```bash
|
|
190
|
+
tyr --add https://raw.githubusercontent.com/someuser/tyr-modules/main/manifest.json my-module
|
|
191
|
+
```
|
|
192
|
+
|
|
193
|
+
```bash
|
|
194
|
+
tyr --modules # list registered modules
|
|
195
|
+
tyr --modules sync # re-check all registered manifests and install anything missing
|
|
196
|
+
tyr --update # git pull ~/.tyr (if linked) and force-refresh all imported commands
|
|
197
|
+
```
|
|
198
|
+
|
|
199
|
+
Rules worth knowing:
|
|
200
|
+
|
|
201
|
+
- A command name that already exists locally is never overwritten by a plain sync — only `tyr --update` (or `tyr --modules sync --force`) refreshes commands that were themselves installed by an import.
|
|
202
|
+
- Hand-written commands are never touched, even if a manifest later defines a command with the same name.
|
|
203
|
+
- If two modules define the same command name, the one processed last (last entry in `imported_modules.yml`) wins.
|
|
204
|
+
- Only `https://` URLs are accepted, both for manifests and for the command files they point to.
|
|
205
|
+
|
|
206
|
+
Which commands came from which module is tracked in `~/.tyr/imported_modules.lock.yml`, generated automatically — you shouldn't need to edit it by hand.
|
|
207
|
+
|
|
208
|
+
#### Publishing your own modules
|
|
209
|
+
|
|
210
|
+
If `~/.tyr` is linked to a GitHub repository (`tyr --config --repo <url>`), `tyr --manifest` generates `~/.tyr/manifest.json` for you, mapping every command in `map.yml` to its `raw.githubusercontent.com` URL on the current branch:
|
|
211
|
+
|
|
212
|
+
```bash
|
|
213
|
+
tyr --manifest
|
|
214
|
+
# → ~/.tyr/manifest.json generated (2 commands)
|
|
215
|
+
```
|
|
216
|
+
|
|
217
|
+
Commit and push it, and anyone can install your commands with `tyr --add <raw-url-to-your-manifest.json>`. A few things to know:
|
|
218
|
+
|
|
219
|
+
- Requires a GitHub remote named `origin` — other hosts aren't supported, since the URLs only resolve through `raw.githubusercontent.com`.
|
|
220
|
+
- If `~/.tyr` isn't a git repository (or has no `origin` remote), `--manifest` refuses and tells you why instead of generating a broken file.
|
|
221
|
+
- Commands whose file lives outside `~/.tyr` (an absolute path elsewhere on disk) are skipped, since they can't be resolved to a URL in the repo.
|
|
222
|
+
|
|
176
223
|
### `tyr chat [directory] [--port <n>] [--split <0-1>]`
|
|
177
224
|
|
|
178
225
|
Opens a local web app with a chat pane and a file browser for `directory` (defaults to the current directory). You can attach images to a message, drag the divider between the two panes to resize them, and click any file in the browser to preview it. Messages are answered by the configured AI vendor (see `AIVendorManager`, below) by default.
|
package/package.json
CHANGED
package/src/core/Kernel.ts
CHANGED
|
@@ -12,6 +12,7 @@ import doc from './sys/doc';
|
|
|
12
12
|
import chat from './sys/chat';
|
|
13
13
|
import config from './sys/config';
|
|
14
14
|
import help from './sys/help';
|
|
15
|
+
import modulesCommand, { syncModules } from './sys/modules';
|
|
15
16
|
|
|
16
17
|
import { TyrError } from './TyrError';
|
|
17
18
|
|
|
@@ -80,8 +81,11 @@ export class Kernel {
|
|
|
80
81
|
console.log('Usage: tyr <command> [args...]');
|
|
81
82
|
console.log(' tyr --config Configure Tyr for the first time');
|
|
82
83
|
console.log(' tyr --version Show version');
|
|
83
|
-
console.log(' tyr --update Pull latest changes from the linked ~/.tyr repo');
|
|
84
|
+
console.log(' tyr --update Pull latest changes from the linked ~/.tyr repo and refresh imported modules');
|
|
84
85
|
console.log(' tyr --upgrade Upgrade Tyr to the latest npm version');
|
|
86
|
+
console.log(' tyr --modules List or sync imported modules');
|
|
87
|
+
console.log(' tyr --add <url> Register and sync a manifest.json as an imported module');
|
|
88
|
+
console.log(' tyr --manifest Generate ~/.tyr/manifest.json from your commands (requires a GitHub-linked repo)');
|
|
85
89
|
return;
|
|
86
90
|
}
|
|
87
91
|
|
|
@@ -95,15 +99,32 @@ export class Kernel {
|
|
|
95
99
|
if (commandName === '--update') {
|
|
96
100
|
const shell = this.container.get().shell;
|
|
97
101
|
const gitDir = path.join(this.userRoot, '.git');
|
|
98
|
-
|
|
99
|
-
|
|
102
|
+
|
|
103
|
+
if (fs.existsSync(gitDir)) {
|
|
104
|
+
console.log('Updating ~/.tyr from repository...');
|
|
105
|
+
shell.cd(this.userRoot);
|
|
106
|
+
await shell.exec('git pull');
|
|
107
|
+
console.log('Repository update complete.');
|
|
108
|
+
} else {
|
|
109
|
+
console.log('~/.tyr is not linked to any git repository. Skipping git pull.');
|
|
100
110
|
console.log('Run: tyr --config --repo <url> to link it.');
|
|
101
|
-
return;
|
|
102
111
|
}
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
112
|
+
|
|
113
|
+
const modulesPath = path.join(this.userRoot, 'imported_modules.yml');
|
|
114
|
+
if (fs.existsSync(modulesPath)) {
|
|
115
|
+
console.log('\nUpdating imported modules...');
|
|
116
|
+
const updateContext = {
|
|
117
|
+
...this.container.get(),
|
|
118
|
+
frameworkRoot: this.frameworkRoot,
|
|
119
|
+
userRoot: this.userRoot,
|
|
120
|
+
run: async () => {},
|
|
121
|
+
task: async <T>(_: string, action: () => Promise<T> | T) => action(),
|
|
122
|
+
fail: (msg: string) => { throw new Error(msg); },
|
|
123
|
+
} as any;
|
|
124
|
+
|
|
125
|
+
await syncModules(updateContext, { force: true });
|
|
126
|
+
}
|
|
127
|
+
|
|
107
128
|
return;
|
|
108
129
|
}
|
|
109
130
|
|
|
@@ -163,6 +184,21 @@ export class Kernel {
|
|
|
163
184
|
return;
|
|
164
185
|
}
|
|
165
186
|
|
|
187
|
+
if (commandName === '--modules') {
|
|
188
|
+
await modulesCommand(context)(args.slice(1));
|
|
189
|
+
return;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
if (commandName === '--add') {
|
|
193
|
+
await modulesCommand(context)(['add', ...args.slice(1)]);
|
|
194
|
+
return;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
if (commandName === '--manifest') {
|
|
198
|
+
await modulesCommand(context)(['manifest']);
|
|
199
|
+
return;
|
|
200
|
+
}
|
|
201
|
+
|
|
166
202
|
const systemCommands: Record<string, CommandFactory> = {
|
|
167
203
|
gen,
|
|
168
204
|
rem,
|
package/src/core/sys/help.ts
CHANGED
|
@@ -72,8 +72,11 @@ export default function help({ userRoot }: TyrContext) {
|
|
|
72
72
|
{ name: '--help', description: 'Shows this command listing.', usage: 'tyr --help' },
|
|
73
73
|
{ name: '--version', description: 'Shows the installed version of tyr.', usage: 'tyr --version' },
|
|
74
74
|
{ name: '--config', description: 'Configures tyr for the first time.', usage: 'tyr --config' },
|
|
75
|
-
{ name: '--update', description: 'Updates ~/.tyr from the git repository.',
|
|
75
|
+
{ name: '--update', description: 'Updates ~/.tyr from the git repository and refreshes imported modules.', usage: 'tyr --update' },
|
|
76
76
|
{ name: '--upgrade', description: 'Upgrades the tyr npm package.', usage: 'tyr --upgrade' },
|
|
77
|
+
{ name: '--modules', description: 'Lists or syncs modules imported via manifest.json.', usage: 'tyr --modules [sync|list]' },
|
|
78
|
+
{ name: '--add', description: 'Registers a manifest.json URL as an imported module and syncs it.', usage: 'tyr --add <manifest-url> [name]' },
|
|
79
|
+
{ name: '--manifest', description: 'Generates ~/.tyr/manifest.json from your commands (needs a GitHub-linked repo).', usage: 'tyr --manifest' },
|
|
77
80
|
{ name: 'gen', description: 'Generates a new command from a description using AI.', usage: 'tyr gen <name> "<description>"' },
|
|
78
81
|
{ name: 'doc', description: 'Opens the framework documentation in the browser.', usage: 'tyr doc' },
|
|
79
82
|
{ name: 'chat', description: 'Opens an AI chat + file browser for a directory.', usage: 'tyr chat [directory] [--port <n>] [--split <0-1>]' },
|
|
@@ -0,0 +1,414 @@
|
|
|
1
|
+
import path from 'path';
|
|
2
|
+
import yaml from 'js-yaml';
|
|
3
|
+
import type { TyrContext } from '../Kernel';
|
|
4
|
+
import { TyrError } from '../TyrError';
|
|
5
|
+
|
|
6
|
+
interface TyrConfig {
|
|
7
|
+
commands: Record<string, string>;
|
|
8
|
+
aliases?: Record<string, string>;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
interface ImportedModulesFile {
|
|
12
|
+
modules: Record<string, string>;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
interface ManagedCommand {
|
|
16
|
+
module: string;
|
|
17
|
+
manifest: string;
|
|
18
|
+
source: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
interface ModulesLock {
|
|
22
|
+
commands: Record<string, ManagedCommand>;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface SyncSummary {
|
|
26
|
+
installed: string[];
|
|
27
|
+
updated: string[];
|
|
28
|
+
skipped: string[];
|
|
29
|
+
failed: { command: string; reason: string }[];
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const IMPORTED_MODULES_FILE = 'imported_modules.yml';
|
|
33
|
+
const LOCK_FILE = 'imported_modules.lock.yml';
|
|
34
|
+
|
|
35
|
+
function isValidHttpsUrl(value: string): boolean {
|
|
36
|
+
try {
|
|
37
|
+
const url = new URL(value);
|
|
38
|
+
return url.protocol === 'https:';
|
|
39
|
+
} catch {
|
|
40
|
+
return false;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function isValidCommandName(name: string): boolean {
|
|
45
|
+
return /^[a-zA-Z0-9_-]+$/.test(name);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function slugFromUrl(url: string): string {
|
|
49
|
+
try {
|
|
50
|
+
const parsed = new URL(url);
|
|
51
|
+
const base = parsed.pathname.split('/').filter(Boolean).pop() ?? 'module';
|
|
52
|
+
const withoutExt = base.replace(/\.json$/i, '').replace(/\.manifest$/i, '');
|
|
53
|
+
const slug = withoutExt.toLowerCase().replace(/[^a-z0-9_-]+/g, '-').replace(/^-+|-+$/g, '');
|
|
54
|
+
return slug || 'module';
|
|
55
|
+
} catch {
|
|
56
|
+
return 'module';
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
async function loadYaml<T>(fs: any, filePath: string, fallback: T): Promise<T> {
|
|
61
|
+
const raw = await fs.read(filePath);
|
|
62
|
+
if (!raw) return fallback;
|
|
63
|
+
try {
|
|
64
|
+
return (yaml.load(raw) as T) ?? fallback;
|
|
65
|
+
} catch {
|
|
66
|
+
return fallback;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
async function saveYaml(fs: any, filePath: string, data: unknown): Promise<void> {
|
|
71
|
+
await fs.write(filePath, yaml.dump(data, { indent: 2, lineWidth: -1 }));
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Fetches a manifest.json (name -> raw file URL) and validates its shape.
|
|
76
|
+
* Only https URLs and safe command names are accepted, to avoid path
|
|
77
|
+
* traversal and to keep imported code coming from a known-good transport.
|
|
78
|
+
*/
|
|
79
|
+
async function fetchManifest(web: any, manifestUrl: string): Promise<Record<string, string>> {
|
|
80
|
+
if (!isValidHttpsUrl(manifestUrl)) {
|
|
81
|
+
throw new TyrError(`Manifest URL must use https: ${manifestUrl}`);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const raw = await web.get(manifestUrl);
|
|
85
|
+
const parsed = typeof raw === 'string' ? JSON.parse(raw) : raw;
|
|
86
|
+
|
|
87
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
88
|
+
throw new TyrError(`Manifest at ${manifestUrl} is not a valid JSON object.`);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
for (const [name, fileUrl] of Object.entries(parsed)) {
|
|
92
|
+
if (!isValidCommandName(name)) {
|
|
93
|
+
throw new TyrError(`Manifest at ${manifestUrl} has an invalid command name: '${name}'`);
|
|
94
|
+
}
|
|
95
|
+
if (typeof fileUrl !== 'string' || !isValidHttpsUrl(fileUrl)) {
|
|
96
|
+
throw new TyrError(`Manifest at ${manifestUrl} has an invalid (non-https) URL for command '${name}'.`);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
return parsed as Record<string, string>;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Reconciles ~/.tyr/imported_modules.yml against ~/.tyr/map.yml.
|
|
105
|
+
*
|
|
106
|
+
* - A command that doesn't exist yet in map.yml is installed.
|
|
107
|
+
* - A command that already exists and isn't tracked in the lockfile is left
|
|
108
|
+
* untouched (it predates the import, or was hand-written).
|
|
109
|
+
* - A command tracked in the lockfile is only re-downloaded when `force` is
|
|
110
|
+
* set (used by `tyr --update`), so day-to-day syncs never clobber content
|
|
111
|
+
* silently.
|
|
112
|
+
* - If two modules define the same command name, the last one processed
|
|
113
|
+
* wins (modules are processed in the order they appear in
|
|
114
|
+
* imported_modules.yml).
|
|
115
|
+
*/
|
|
116
|
+
export async function syncModules(context: TyrContext, options: { force?: boolean } = {}): Promise<SyncSummary> {
|
|
117
|
+
const { logger, fs, web, userRoot } = context as any;
|
|
118
|
+
const force = options.force ?? false;
|
|
119
|
+
|
|
120
|
+
const summary: SyncSummary = { installed: [], updated: [], skipped: [], failed: [] };
|
|
121
|
+
|
|
122
|
+
const modulesPath = path.join(userRoot, IMPORTED_MODULES_FILE);
|
|
123
|
+
const mapPath = path.join(userRoot, 'map.yml');
|
|
124
|
+
const lockPath = path.join(userRoot, LOCK_FILE);
|
|
125
|
+
|
|
126
|
+
if (!fs.exists(modulesPath)) {
|
|
127
|
+
logger.info(`No ${IMPORTED_MODULES_FILE} found. Nothing to sync.`);
|
|
128
|
+
return summary;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const modulesFile = await loadYaml<ImportedModulesFile>(fs, modulesPath, { modules: {} });
|
|
132
|
+
const mapConfig = await loadYaml<TyrConfig>(fs, mapPath, { commands: {} });
|
|
133
|
+
const lock = await loadYaml<ModulesLock>(fs, lockPath, { commands: {} });
|
|
134
|
+
|
|
135
|
+
if (!mapConfig.commands) mapConfig.commands = {};
|
|
136
|
+
if (!lock.commands) lock.commands = {};
|
|
137
|
+
|
|
138
|
+
// Snapshot the state as it was *before* this run. Collisions between two
|
|
139
|
+
// modules processed in the same run are resolved by processing order
|
|
140
|
+
// (last one wins), independently of the --force flag; only commands that
|
|
141
|
+
// already existed prior to this run are subject to the install/skip/
|
|
142
|
+
// force-update rules below.
|
|
143
|
+
const mapSnapshot = { ...mapConfig.commands };
|
|
144
|
+
const lockSnapshot = { ...lock.commands };
|
|
145
|
+
|
|
146
|
+
let mapChanged = false;
|
|
147
|
+
let lockChanged = false;
|
|
148
|
+
|
|
149
|
+
for (const [moduleName, manifestUrl] of Object.entries(modulesFile.modules ?? {})) {
|
|
150
|
+
logger.info(`Syncing module '${moduleName}' (${manifestUrl})...`);
|
|
151
|
+
|
|
152
|
+
let manifest: Record<string, string>;
|
|
153
|
+
try {
|
|
154
|
+
manifest = await fetchManifest(web, manifestUrl);
|
|
155
|
+
} catch (e) {
|
|
156
|
+
const reason = e instanceof Error ? e.message : String(e);
|
|
157
|
+
logger.error(`Could not read manifest for module '${moduleName}': ${reason}`);
|
|
158
|
+
summary.failed.push({ command: moduleName, reason });
|
|
159
|
+
continue;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
for (const [commandName, fileUrl] of Object.entries(manifest)) {
|
|
163
|
+
const alreadyInMap = !!mapSnapshot[commandName];
|
|
164
|
+
const managed = lockSnapshot[commandName];
|
|
165
|
+
|
|
166
|
+
const shouldInstall = !alreadyInMap;
|
|
167
|
+
const shouldForceUpdate = force && !!managed;
|
|
168
|
+
|
|
169
|
+
if (!shouldInstall && !shouldForceUpdate) {
|
|
170
|
+
if (alreadyInMap && !managed) {
|
|
171
|
+
logger.info(`Skipping '${commandName}': already exists and is not managed by an import.`);
|
|
172
|
+
} else {
|
|
173
|
+
logger.info(`Skipping '${commandName}': already installed. Use 'tyr --update' to refresh.`);
|
|
174
|
+
}
|
|
175
|
+
summary.skipped.push(commandName);
|
|
176
|
+
continue;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
try {
|
|
180
|
+
const content = await web.get(fileUrl);
|
|
181
|
+
const fileContent = typeof content === 'string' ? content : JSON.stringify(content, null, 2);
|
|
182
|
+
|
|
183
|
+
const destPath = path.join(userRoot, 'commands', `${commandName}.tyr.ts`);
|
|
184
|
+
await fs.write(destPath, fileContent);
|
|
185
|
+
|
|
186
|
+
if (managed && managed.module !== moduleName) {
|
|
187
|
+
logger.warn(`'${commandName}' was previously managed by module '${managed.module}'; now owned by '${moduleName}'.`);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
mapConfig.commands[commandName] = `./commands/${commandName}.tyr.ts`;
|
|
191
|
+
lock.commands[commandName] = { module: moduleName, manifest: manifestUrl, source: fileUrl };
|
|
192
|
+
mapChanged = true;
|
|
193
|
+
lockChanged = true;
|
|
194
|
+
|
|
195
|
+
if (shouldInstall) {
|
|
196
|
+
logger.success(`Installed '${commandName}' from module '${moduleName}'.`);
|
|
197
|
+
summary.installed.push(commandName);
|
|
198
|
+
} else {
|
|
199
|
+
logger.success(`Updated '${commandName}' from module '${moduleName}'.`);
|
|
200
|
+
summary.updated.push(commandName);
|
|
201
|
+
}
|
|
202
|
+
} catch (e) {
|
|
203
|
+
const reason = e instanceof Error ? e.message : String(e);
|
|
204
|
+
logger.error(`Could not download '${commandName}' from '${fileUrl}': ${reason}`);
|
|
205
|
+
summary.failed.push({ command: commandName, reason });
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
if (mapChanged) await saveYaml(fs, mapPath, mapConfig);
|
|
211
|
+
if (lockChanged) await saveYaml(fs, lockPath, lock);
|
|
212
|
+
|
|
213
|
+
logger.info(
|
|
214
|
+
`Sync finished — installed: ${summary.installed.length}, updated: ${summary.updated.length}, ` +
|
|
215
|
+
`skipped: ${summary.skipped.length}, failed: ${summary.failed.length}.`
|
|
216
|
+
);
|
|
217
|
+
|
|
218
|
+
return summary;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* Registers a manifest URL under a module name in imported_modules.yml and
|
|
223
|
+
* immediately syncs it (downloads whatever commands are missing).
|
|
224
|
+
*/
|
|
225
|
+
export async function addModule(context: TyrContext, manifestUrl: string, name?: string): Promise<void> {
|
|
226
|
+
const { logger, fs, web, userRoot } = context as any;
|
|
227
|
+
|
|
228
|
+
if (!manifestUrl) {
|
|
229
|
+
logger.error('Missing manifest URL.');
|
|
230
|
+
logger.info('Usage: tyr --add <manifest-url> [name]');
|
|
231
|
+
return;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
if (!isValidHttpsUrl(manifestUrl)) {
|
|
235
|
+
logger.error('Manifest URL must be a valid https:// URL.');
|
|
236
|
+
return;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
logger.info(`Validating manifest: ${manifestUrl}`);
|
|
240
|
+
try {
|
|
241
|
+
await fetchManifest(web, manifestUrl);
|
|
242
|
+
} catch (e) {
|
|
243
|
+
const reason = e instanceof Error ? e.message : String(e);
|
|
244
|
+
logger.error(`Could not add module: ${reason}`);
|
|
245
|
+
return;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
const moduleName = name && isValidCommandName(name) ? name : slugFromUrl(manifestUrl);
|
|
249
|
+
|
|
250
|
+
const modulesPath = path.join(userRoot, IMPORTED_MODULES_FILE);
|
|
251
|
+
const modulesFile = await loadYaml<ImportedModulesFile>(fs, modulesPath, { modules: {} });
|
|
252
|
+
if (!modulesFile.modules) modulesFile.modules = {};
|
|
253
|
+
|
|
254
|
+
if (modulesFile.modules[moduleName] && modulesFile.modules[moduleName] !== manifestUrl) {
|
|
255
|
+
logger.warn(`Module '${moduleName}' already pointed to a different manifest. Overwriting.`);
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
modulesFile.modules[moduleName] = manifestUrl;
|
|
259
|
+
await saveYaml(fs, modulesPath, modulesFile);
|
|
260
|
+
logger.success(`Module '${moduleName}' registered in ${modulesPath}`);
|
|
261
|
+
|
|
262
|
+
await syncModules(context);
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
interface GithubRepoInfo {
|
|
266
|
+
owner: string;
|
|
267
|
+
repo: string;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/**
|
|
271
|
+
* Parses the owner/repo out of a GitHub remote URL. Only github.com is
|
|
272
|
+
* supported, since manifest.json entries are meant to resolve through
|
|
273
|
+
* raw.githubusercontent.com.
|
|
274
|
+
*/
|
|
275
|
+
function parseGithubRemote(remoteUrl: string): GithubRepoInfo | null {
|
|
276
|
+
const patterns = [
|
|
277
|
+
/^https?:\/\/github\.com\/([^/]+)\/([^/]+?)(\.git)?\/?$/,
|
|
278
|
+
/^git@github\.com:([^/]+)\/([^/]+?)(\.git)?$/,
|
|
279
|
+
/^ssh:\/\/git@github\.com\/([^/]+)\/([^/]+?)(\.git)?$/,
|
|
280
|
+
];
|
|
281
|
+
|
|
282
|
+
for (const pattern of patterns) {
|
|
283
|
+
const match = remoteUrl.trim().match(pattern);
|
|
284
|
+
if (match) return { owner: match[1], repo: match[2] };
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
return null;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/**
|
|
291
|
+
* Generates ~/.tyr/manifest.json from the commands currently registered in
|
|
292
|
+
* map.yml, pointing each one at its raw.githubusercontent.com URL. This is
|
|
293
|
+
* the publishing counterpart of `tyr --add`: whatever this produces can be
|
|
294
|
+
* handed to someone else, who registers it with `tyr --add <url>`.
|
|
295
|
+
*
|
|
296
|
+
* Requires ~/.tyr to be a git repository linked to a GitHub remote — there's
|
|
297
|
+
* no way to build a raw.githubusercontent.com URL otherwise.
|
|
298
|
+
*/
|
|
299
|
+
export async function generateManifest(context: TyrContext): Promise<void> {
|
|
300
|
+
const { logger, fs, shell, userRoot } = context as any;
|
|
301
|
+
|
|
302
|
+
const gitDir = path.join(userRoot, '.git');
|
|
303
|
+
if (!fs.exists(gitDir)) {
|
|
304
|
+
logger.error('~/.tyr has no linked git repository. Cannot generate manifest.json.');
|
|
305
|
+
logger.info('Link one first: tyr --config --repo <url>');
|
|
306
|
+
return;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
shell.cd(userRoot);
|
|
310
|
+
|
|
311
|
+
let remoteUrl: string;
|
|
312
|
+
try {
|
|
313
|
+
remoteUrl = await shell.exec('git remote get-url origin');
|
|
314
|
+
} catch {
|
|
315
|
+
logger.error("~/.tyr's repository has no 'origin' remote configured. Cannot generate manifest.json.");
|
|
316
|
+
return;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
const repoInfo = parseGithubRemote(remoteUrl);
|
|
320
|
+
if (!repoInfo) {
|
|
321
|
+
logger.error(`Cannot generate manifest.json: only GitHub repositories are supported (raw.githubusercontent.com). Remote was: ${remoteUrl}`);
|
|
322
|
+
return;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
let branch = 'main';
|
|
326
|
+
try {
|
|
327
|
+
const currentBranch = await shell.exec('git rev-parse --abbrev-ref HEAD');
|
|
328
|
+
if (currentBranch && currentBranch !== 'HEAD') {
|
|
329
|
+
branch = currentBranch;
|
|
330
|
+
} else {
|
|
331
|
+
logger.warn(`Could not determine the current branch (detached HEAD?). Falling back to '${branch}'.`);
|
|
332
|
+
}
|
|
333
|
+
} catch {
|
|
334
|
+
logger.warn(`Could not determine the current branch. Falling back to '${branch}'.`);
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
const mapPath = path.join(userRoot, 'map.yml');
|
|
338
|
+
const mapConfig = await loadYaml<TyrConfig>(fs, mapPath, { commands: {} });
|
|
339
|
+
const commands = mapConfig.commands ?? {};
|
|
340
|
+
|
|
341
|
+
const manifest: Record<string, string> = {};
|
|
342
|
+
const skipped: string[] = [];
|
|
343
|
+
|
|
344
|
+
for (const [name, scriptPath] of Object.entries(commands)) {
|
|
345
|
+
const repoRelativePath = path.isAbsolute(scriptPath)
|
|
346
|
+
? path.relative(userRoot, scriptPath)
|
|
347
|
+
: scriptPath.replace(/^\.[/\\]/, '');
|
|
348
|
+
|
|
349
|
+
if (repoRelativePath.startsWith('..')) {
|
|
350
|
+
logger.warn(`Skipping '${name}': its file lives outside ~/.tyr and can't be published.`);
|
|
351
|
+
skipped.push(name);
|
|
352
|
+
continue;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
const normalized = repoRelativePath.split(path.sep).join('/');
|
|
356
|
+
manifest[name] = `https://raw.githubusercontent.com/${repoInfo.owner}/${repoInfo.repo}/${branch}/${normalized}`;
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
if (Object.keys(manifest).length === 0) {
|
|
360
|
+
logger.warn('No commands to include in manifest.json.');
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
const manifestPath = path.join(userRoot, 'manifest.json');
|
|
364
|
+
await fs.write(manifestPath, JSON.stringify(manifest, null, 2) + '\n');
|
|
365
|
+
|
|
366
|
+
logger.success(
|
|
367
|
+
`manifest.json generated at ${manifestPath} (${Object.keys(manifest).length} commands` +
|
|
368
|
+
`${skipped.length ? `, ${skipped.length} skipped` : ''}).`
|
|
369
|
+
);
|
|
370
|
+
logger.info("Commit and push it so others can install it with: tyr --add <raw-url-to-manifest.json>");
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
export default function modules(context: TyrContext) {
|
|
374
|
+
return async (args: string[]) => {
|
|
375
|
+
const { logger, fs, userRoot } = context as any;
|
|
376
|
+
const sub = args[0];
|
|
377
|
+
|
|
378
|
+
switch (sub) {
|
|
379
|
+
case 'sync': {
|
|
380
|
+
await syncModules(context, { force: args.includes('--force') });
|
|
381
|
+
return;
|
|
382
|
+
}
|
|
383
|
+
case 'add': {
|
|
384
|
+
await addModule(context, args[1], args[2]);
|
|
385
|
+
return;
|
|
386
|
+
}
|
|
387
|
+
case 'manifest': {
|
|
388
|
+
await generateManifest(context);
|
|
389
|
+
return;
|
|
390
|
+
}
|
|
391
|
+
case 'list':
|
|
392
|
+
case undefined: {
|
|
393
|
+
const modulesPath = path.join(userRoot, IMPORTED_MODULES_FILE);
|
|
394
|
+
const modulesFile = await loadYaml<ImportedModulesFile>(fs, modulesPath, { modules: {} });
|
|
395
|
+
const entries = Object.entries(modulesFile.modules ?? {});
|
|
396
|
+
|
|
397
|
+
if (entries.length === 0) {
|
|
398
|
+
logger.info('No imported modules registered.');
|
|
399
|
+
logger.info('Add one with: tyr --add <manifest-url> [name]');
|
|
400
|
+
return;
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
logger.info('Imported modules:');
|
|
404
|
+
for (const [name, url] of entries) {
|
|
405
|
+
logger.info(` ${name} -> ${url}`);
|
|
406
|
+
}
|
|
407
|
+
return;
|
|
408
|
+
}
|
|
409
|
+
default:
|
|
410
|
+
logger.error(`Unknown subcommand '${sub}'.`);
|
|
411
|
+
logger.info('Usage: tyr --modules [sync|list|manifest]');
|
|
412
|
+
}
|
|
413
|
+
};
|
|
414
|
+
}
|