@orxataguy/tyr 1.0.43 → 1.0.45

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 CHANGED
@@ -180,22 +180,30 @@ Third parties can distribute commands as a `manifest.json` — a flat JSON map o
180
180
  ```json
181
181
  {
182
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"
183
+ "rollback": "https://raw.githubusercontent.com/someuser/tyr-modules/main/rollback.tyr.ts",
184
+ "$env": "https://raw.githubusercontent.com/someuser/tyr-modules/main/.env.example"
184
185
  }
185
186
  ```
186
187
 
187
- Register one with `tyr --add`, which validates the manifest, records it in `~/.tyr/imported_modules.yaml`, and immediately downloads whatever commands are missing:
188
+ `$env` is optional and reserved (`$` can never appear in a command name, so it never collides with one). If present, it's downloaded as `~/.tyr/.env.<module-name>.example` a template of whatever environment variables that module's commands expect. It follows the exact same install/skip/force-update rules as commands below, and `tyr --del <module-name>` removes it along with everything else that module owns.
189
+
190
+ Register one with `tyr --add`, which validates the manifest, records it in `~/.tyr/imported_modules.yaml`, and immediately downloads whatever commands (and `$env`, if present) are missing:
188
191
 
189
192
  ```bash
190
193
  tyr --add https://raw.githubusercontent.com/someuser/tyr-modules/main/manifest.json my-module
191
194
  ```
192
195
 
196
+ The module name is optional. If you omit it and the manifest URL is a `raw.githubusercontent.com` link, the repository name is used (`tyr-modules` for the URL above). For any other host, it falls back to a slug of the manifest file's own name.
197
+
193
198
  ```bash
194
199
  tyr --modules # list registered modules
195
200
  tyr --modules sync # re-check all registered manifests and install anything missing
196
201
  tyr --update # git pull ~/.tyr (if linked) and force-refresh all imported commands
202
+ tyr --del my-module # unregister a module: removes its commands, map.yml entries and lock records
197
203
  ```
198
204
 
205
+ `tyr --del <module-name>` is the counterpart to `--add` — it looks up every command the lockfile says that module owns, deletes the command file, drops it (and any alias pointing to it) from `map.yml`, clears its lockfile entry, and finally removes the module from `imported_modules.yaml`. Commands that aren't tracked as belonging to that module (hand-written, or now owned by a different module after a collision) are left untouched.
206
+
199
207
  Rules worth knowing:
200
208
 
201
209
  - 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.
@@ -219,6 +227,7 @@ Commit and push it, and anyone can install your commands with `tyr --add <raw-ur
219
227
  - Requires a GitHub remote named `origin` — other hosts aren't supported, since the URLs only resolve through `raw.githubusercontent.com`.
220
228
  - 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
229
  - 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.
230
+ - If `~/.tyr/.env.example` exists, it's automatically included as `$env`, so whoever runs `tyr --add` on your manifest gets it as `.env.<module-name>.example`.
222
231
 
223
232
  ### `tyr chat [directory] [--port <n>] [--split <0-1>]`
224
233
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@orxataguy/tyr",
3
- "version": "1.0.43",
3
+ "version": "1.0.45",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "tyr": "./bin/tyr.js"
@@ -85,6 +85,7 @@ export class Kernel {
85
85
  console.log(' tyr --upgrade Upgrade Tyr to the latest npm version');
86
86
  console.log(' tyr --modules List or sync imported modules');
87
87
  console.log(' tyr --add <url> Register and sync a manifest.json as an imported module');
88
+ console.log(' tyr --del <name> Unregister an imported module and remove its commands');
88
89
  console.log(' tyr --manifest Generate ~/.tyr/manifest.json from your commands (requires a GitHub-linked repo)');
89
90
  return;
90
91
  }
@@ -194,6 +195,11 @@ export class Kernel {
194
195
  return;
195
196
  }
196
197
 
198
+ if (commandName === '--del') {
199
+ await modulesCommand(context)(['del', ...args.slice(1)]);
200
+ return;
201
+ }
202
+
197
203
  if (commandName === '--manifest') {
198
204
  await modulesCommand(context)(['manifest']);
199
205
  return;
@@ -76,6 +76,7 @@ export default function help({ userRoot }: TyrContext) {
76
76
  { name: '--upgrade', description: 'Upgrades the tyr npm package.', usage: 'tyr --upgrade' },
77
77
  { name: '--modules', description: 'Lists or syncs modules imported via manifest.json.', usage: 'tyr --modules [sync|list]' },
78
78
  { name: '--add', description: 'Registers a manifest.json URL as an imported module and syncs it.', usage: 'tyr --add <manifest-url> [name]' },
79
+ { name: '--del', description: 'Unregisters an imported module and removes the commands it owns.', usage: 'tyr --del <module-name>' },
79
80
  { name: '--manifest', description: 'Generates ~/.tyr/manifest.json from your commands (needs a GitHub-linked repo).', usage: 'tyr --manifest' },
80
81
  { name: 'gen', description: 'Generates a new command from a description using AI.', usage: 'tyr gen <name> "<description>"' },
81
82
  { name: 'doc', description: 'Opens the framework documentation in the browser.', usage: 'tyr doc' },
@@ -18,8 +18,15 @@ interface ManagedCommand {
18
18
  source: string;
19
19
  }
20
20
 
21
+ interface ManagedEnvFile {
22
+ manifest: string;
23
+ source: string;
24
+ file: string;
25
+ }
26
+
21
27
  interface ModulesLock {
22
28
  commands: Record<string, ManagedCommand>;
29
+ env?: Record<string, ManagedEnvFile>;
23
30
  }
24
31
 
25
32
  export interface SyncSummary {
@@ -56,13 +63,31 @@ function toSafeFilename(commandName: string): string {
56
63
  return commandName.replace(/:/g, '-');
57
64
  }
58
65
 
59
- function slugFromUrl(url: string): string {
66
+ function sanitizeSlug(value: string): string {
67
+ const slug = value.toLowerCase().replace(/[^a-z0-9_-]+/g, '-').replace(/^-+|-+$/g, '');
68
+ return slug || 'module';
69
+ }
70
+
71
+ /**
72
+ * Default module name when `tyr --add <url>` is called without one.
73
+ * For a raw.githubusercontent.com URL (the expected shape:
74
+ * raw.githubusercontent.com/<owner>/<repo>/<branch>/<path>) this uses the
75
+ * repository name, since that's what identifies where the module comes from.
76
+ * For any other host — a manifest can be served from anywhere, as long as
77
+ * it's https — falls back to a slug of the manifest file's own name.
78
+ */
79
+ function defaultModuleNameFromUrl(url: string): string {
60
80
  try {
61
81
  const parsed = new URL(url);
62
- const base = parsed.pathname.split('/').filter(Boolean).pop() ?? 'module';
82
+ const segments = parsed.pathname.split('/').filter(Boolean);
83
+
84
+ if (parsed.hostname === 'raw.githubusercontent.com' && segments.length >= 2) {
85
+ return sanitizeSlug(segments[1]);
86
+ }
87
+
88
+ const base = segments.pop() ?? 'module';
63
89
  const withoutExt = base.replace(/\.json$/i, '').replace(/\.manifest$/i, '');
64
- const slug = withoutExt.toLowerCase().replace(/[^a-z0-9_-]+/g, '-').replace(/^-+|-+$/g, '');
65
- return slug || 'module';
90
+ return sanitizeSlug(withoutExt);
66
91
  } catch {
67
92
  return 'module';
68
93
  }
@@ -82,12 +107,24 @@ async function saveYaml(fs: any, filePath: string, data: unknown): Promise<void>
82
107
  await fs.write(filePath, yaml.dump(data, { indent: 2, lineWidth: -1 }));
83
108
  }
84
109
 
110
+ // Reserved manifest key for an optional .env.example reference. '$' can never
111
+ // appear in a valid command name (see isValidCommandName), so it can't collide
112
+ // with a real command.
113
+ const ENV_MANIFEST_KEY = '$env';
114
+
115
+ interface ParsedManifest {
116
+ commands: Record<string, string>;
117
+ envUrl?: string;
118
+ }
119
+
85
120
  /**
86
121
  * Fetches a manifest.json (name -> raw file URL) and validates its shape.
87
122
  * Only https URLs and safe command names are accepted, to avoid path
88
123
  * traversal and to keep imported code coming from a known-good transport.
124
+ * A manifest may also carry a reserved `$env` key pointing at a raw
125
+ * .env.example file for the module's required environment variables.
89
126
  */
90
- async function fetchManifest(web: any, manifestUrl: string): Promise<Record<string, string>> {
127
+ async function fetchManifest(web: any, manifestUrl: string): Promise<ParsedManifest> {
91
128
  if (!isValidHttpsUrl(manifestUrl)) {
92
129
  throw new TyrError(`Manifest URL must use https: ${manifestUrl}`);
93
130
  }
@@ -99,16 +136,28 @@ async function fetchManifest(web: any, manifestUrl: string): Promise<Record<stri
99
136
  throw new TyrError(`Manifest at ${manifestUrl} is not a valid JSON object.`);
100
137
  }
101
138
 
102
- for (const [name, fileUrl] of Object.entries(parsed)) {
139
+ const commands: Record<string, string> = {};
140
+ let envUrl: string | undefined;
141
+
142
+ for (const [name, value] of Object.entries(parsed)) {
143
+ if (name === ENV_MANIFEST_KEY) {
144
+ if (typeof value !== 'string' || !isValidHttpsUrl(value)) {
145
+ throw new TyrError(`Manifest at ${manifestUrl} has an invalid (non-https) URL for '${ENV_MANIFEST_KEY}'.`);
146
+ }
147
+ envUrl = value;
148
+ continue;
149
+ }
150
+
103
151
  if (!isValidCommandName(name)) {
104
152
  throw new TyrError(`Manifest at ${manifestUrl} has an invalid command name: '${name}'`);
105
153
  }
106
- if (typeof fileUrl !== 'string' || !isValidHttpsUrl(fileUrl)) {
154
+ if (typeof value !== 'string' || !isValidHttpsUrl(value)) {
107
155
  throw new TyrError(`Manifest at ${manifestUrl} has an invalid (non-https) URL for command '${name}'.`);
108
156
  }
157
+ commands[name] = value;
109
158
  }
110
159
 
111
- return parsed as Record<string, string>;
160
+ return { commands, envUrl };
112
161
  }
113
162
 
114
163
  /**
@@ -145,6 +194,7 @@ export async function syncModules(context: TyrContext, options: { force?: boolea
145
194
 
146
195
  if (!mapConfig.commands) mapConfig.commands = {};
147
196
  if (!lock.commands) lock.commands = {};
197
+ if (!lock.env) lock.env = {};
148
198
 
149
199
  // Snapshot the state as it was *before* this run. Collisions between two
150
200
  // modules processed in the same run are resolved by processing order
@@ -160,9 +210,9 @@ export async function syncModules(context: TyrContext, options: { force?: boolea
160
210
  for (const [moduleName, manifestUrl] of Object.entries(modulesFile.modules ?? {})) {
161
211
  logger.info(`Syncing module '${moduleName}' (${manifestUrl})...`);
162
212
 
163
- let manifest: Record<string, string>;
213
+ let parsedManifest: ParsedManifest;
164
214
  try {
165
- manifest = await fetchManifest(web, manifestUrl);
215
+ parsedManifest = await fetchManifest(web, manifestUrl);
166
216
  } catch (e) {
167
217
  const reason = e instanceof Error ? e.message : String(e);
168
218
  logger.error(`Could not read manifest for module '${moduleName}': ${reason}`);
@@ -173,6 +223,8 @@ export async function syncModules(context: TyrContext, options: { force?: boolea
173
223
  continue;
174
224
  }
175
225
 
226
+ const { commands: manifest, envUrl } = parsedManifest;
227
+
176
228
  for (const [commandName, fileUrl] of Object.entries(manifest)) {
177
229
  const alreadyInMap = !!mapSnapshot[commandName];
178
230
  const managed = lockSnapshot[commandName];
@@ -221,6 +273,39 @@ export async function syncModules(context: TyrContext, options: { force?: boolea
221
273
  summary.failed.push({ command: commandName, reason });
222
274
  }
223
275
  }
276
+
277
+ if (envUrl) {
278
+ const envFileName = `.env.${toSafeFilename(moduleName)}.example`;
279
+ const envDestPath = path.join(userRoot, envFileName);
280
+ const envManaged = lock.env![moduleName];
281
+
282
+ const shouldInstallEnv = !fs.exists(envDestPath);
283
+ const shouldForceUpdateEnv = force && !!envManaged;
284
+
285
+ if (!shouldInstallEnv && !shouldForceUpdateEnv) {
286
+ logger.info(
287
+ envManaged
288
+ ? `Skipping ${envFileName}: already installed. Use 'tyr --update' to refresh.`
289
+ : `Skipping ${envFileName}: a file with that name already exists and isn't managed by an import.`
290
+ );
291
+ } else {
292
+ try {
293
+ const envContent = await web.get(envUrl);
294
+ const envFileContent = typeof envContent === 'string' ? envContent : JSON.stringify(envContent, null, 2);
295
+ await fs.write(envDestPath, envFileContent);
296
+
297
+ lock.env![moduleName] = { manifest: manifestUrl, source: envUrl, file: envFileName };
298
+ lockChanged = true;
299
+
300
+ logger.success(`${shouldInstallEnv ? 'Installed' : 'Updated'} ${envFileName} from module '${moduleName}'.`);
301
+ } catch (e) {
302
+ const reason = e instanceof Error ? e.message : String(e);
303
+ logger.error(`Could not download ${envFileName} for module '${moduleName}': ${reason}`);
304
+ logger.info(`Could not download ${envFileName} for module '${moduleName}': ${reason}`);
305
+ summary.failed.push({ command: envFileName, reason });
306
+ }
307
+ }
308
+ }
224
309
  }
225
310
 
226
311
  if (mapChanged) await saveYaml(fs, mapPath, mapConfig);
@@ -270,7 +355,7 @@ export async function addModule(context: TyrContext, manifestUrl: string, name?:
270
355
  return;
271
356
  }
272
357
 
273
- const moduleName = name && isValidCommandName(name) ? name : slugFromUrl(manifestUrl);
358
+ const moduleName = name && isValidCommandName(name) ? name : defaultModuleNameFromUrl(manifestUrl);
274
359
 
275
360
  const modulesPath = path.join(userRoot, IMPORTED_MODULES_FILE);
276
361
  const modulesFile = await loadYaml<ImportedModulesFile>(fs, modulesPath, { modules: {} });
@@ -287,6 +372,104 @@ export async function addModule(context: TyrContext, manifestUrl: string, name?:
287
372
  await syncModules(context);
288
373
  }
289
374
 
375
+ /**
376
+ * Unregisters a module: removes it from imported_modules.yaml, deletes every
377
+ * command file it's tracked as owning in the lockfile, drops those commands
378
+ * (and any aliases pointing at them) from map.yml, deletes its
379
+ * `.env.<module>.example` if one was imported, and clears their lockfile
380
+ * entries. The counterpart to `tyr --add` — closes the loop.
381
+ */
382
+ export async function removeModule(context: TyrContext, moduleName: string): Promise<void> {
383
+ const { logger, fs, userRoot } = context as any;
384
+
385
+ if (!moduleName) {
386
+ logger.error('Missing module name.');
387
+ logger.info('Usage: tyr --del <module-name>');
388
+ return;
389
+ }
390
+
391
+ const modulesPath = path.join(userRoot, IMPORTED_MODULES_FILE);
392
+ const mapPath = path.join(userRoot, 'map.yml');
393
+ const lockPath = path.join(userRoot, LOCK_FILE);
394
+
395
+ const modulesFile = await loadYaml<ImportedModulesFile>(fs, modulesPath, { modules: {} });
396
+ const mapConfig = await loadYaml<TyrConfig>(fs, mapPath, { commands: {} });
397
+ const lock = await loadYaml<ModulesLock>(fs, lockPath, { commands: {} });
398
+
399
+ if (!modulesFile.modules) modulesFile.modules = {};
400
+ if (!mapConfig.commands) mapConfig.commands = {};
401
+ if (!lock.commands) lock.commands = {};
402
+ if (!lock.env) lock.env = {};
403
+
404
+ const isRegistered = !!modulesFile.modules[moduleName];
405
+ const managedCommands = Object.entries(lock.commands)
406
+ .filter(([, info]) => info.module === moduleName)
407
+ .map(([commandName]) => commandName);
408
+ const managedEnv = lock.env[moduleName];
409
+
410
+ if (!isRegistered && managedCommands.length === 0 && !managedEnv) {
411
+ logger.error(`Module '${moduleName}' is not registered and owns no commands. Nothing to remove.`);
412
+ logger.info(`Module '${moduleName}' was not found in ${IMPORTED_MODULES_FILE} or ${LOCK_FILE}.`);
413
+ return;
414
+ }
415
+
416
+ let mapChanged = false;
417
+ let lockChanged = false;
418
+
419
+ for (const commandName of managedCommands) {
420
+ const scriptPath = mapConfig.commands[commandName];
421
+ if (scriptPath) {
422
+ const absolutePath = path.isAbsolute(scriptPath) ? scriptPath : path.resolve(userRoot, scriptPath);
423
+ if (fs.exists(absolutePath)) {
424
+ await fs.delete(absolutePath);
425
+ }
426
+ delete mapConfig.commands[commandName];
427
+ mapChanged = true;
428
+ }
429
+
430
+ if (mapConfig.aliases) {
431
+ for (const [alias, target] of Object.entries(mapConfig.aliases)) {
432
+ if (target === commandName) {
433
+ delete mapConfig.aliases[alias];
434
+ mapChanged = true;
435
+ logger.info(`Alias '${alias}' removed (pointed to '${commandName}').`);
436
+ }
437
+ }
438
+ }
439
+
440
+ delete lock.commands[commandName];
441
+ lockChanged = true;
442
+
443
+ logger.success(`Removed command '${commandName}' (was managed by module '${moduleName}').`);
444
+ }
445
+
446
+ if (managedEnv) {
447
+ const envPath = path.join(userRoot, managedEnv.file);
448
+ if (fs.exists(envPath)) {
449
+ await fs.delete(envPath);
450
+ }
451
+ delete lock.env[moduleName];
452
+ lockChanged = true;
453
+ logger.success(`Removed ${managedEnv.file} (was managed by module '${moduleName}').`);
454
+ }
455
+
456
+ if (isRegistered) {
457
+ delete modulesFile.modules[moduleName];
458
+ await saveYaml(fs, modulesPath, modulesFile);
459
+ }
460
+
461
+ if (mapChanged) await saveYaml(fs, mapPath, mapConfig);
462
+ if (lockChanged) await saveYaml(fs, lockPath, lock);
463
+
464
+ const removedItems = [...managedCommands, ...(managedEnv ? [managedEnv.file] : [])];
465
+ const removedList = removedItems.length ? `: ${removedItems.join(', ')}` : '';
466
+ logger.success(
467
+ `Module '${moduleName}' removed` +
468
+ (removedItems.length ? ` — ${removedItems.length} item(s) removed${removedList}.` : ' (it had nothing managed).')
469
+ );
470
+ logger.info(`Module '${moduleName}' unlinked from ${IMPORTED_MODULES_FILE}${removedList}.`);
471
+ }
472
+
290
473
  interface GithubRepoInfo {
291
474
  owner: string;
292
475
  repo: string;
@@ -318,6 +501,10 @@ function parseGithubRemote(remoteUrl: string): GithubRepoInfo | null {
318
501
  * the publishing counterpart of `tyr --add`: whatever this produces can be
319
502
  * handed to someone else, who registers it with `tyr --add <url>`.
320
503
  *
504
+ * If ~/.tyr/.env.example exists, its raw URL is also included under the
505
+ * reserved `$env` key, so `tyr --add` downloads it as `.env.<module>.example`
506
+ * for whoever installs this manifest.
507
+ *
321
508
  * Requires ~/.tyr to be a git repository linked to a GitHub remote — there's
322
509
  * no way to build a raw.githubusercontent.com URL otherwise.
323
510
  */
@@ -383,16 +570,28 @@ export async function generateManifest(context: TyrContext): Promise<void> {
383
570
  manifest[name] = `https://raw.githubusercontent.com/${repoInfo.owner}/${repoInfo.repo}/${branch}/${normalized}`;
384
571
  }
385
572
 
386
- if (Object.keys(manifest).length === 0) {
573
+ const commandCount = Object.keys(manifest).length;
574
+ if (commandCount === 0) {
387
575
  logger.info('No commands to include in manifest.json.');
388
576
  }
389
577
 
578
+ // If ~/.tyr has a .env.example, publish it too — tyr --add downloads it as
579
+ // .env.<module>.example so whoever installs this manifest knows which
580
+ // environment variables these commands expect.
581
+ const envExamplePath = path.join(userRoot, '.env.example');
582
+ let includedEnv = false;
583
+ if (fs.exists(envExamplePath)) {
584
+ manifest[ENV_MANIFEST_KEY] = `https://raw.githubusercontent.com/${repoInfo.owner}/${repoInfo.repo}/${branch}/.env.example`;
585
+ includedEnv = true;
586
+ }
587
+
390
588
  const manifestPath = path.join(userRoot, 'manifest.json');
391
589
  await fs.write(manifestPath, JSON.stringify(manifest, null, 2) + '\n');
392
590
 
393
591
  logger.success(
394
- `manifest.json generated at ${manifestPath} (${Object.keys(manifest).length} commands` +
395
- `${skipped.length ? `, ${skipped.length} skipped` : ''}).`
592
+ `manifest.json generated at ${manifestPath} (${commandCount} commands` +
593
+ `${skipped.length ? `, ${skipped.length} skipped` : ''}` +
594
+ `${includedEnv ? ', includes .env.example' : ''}).`
396
595
  );
397
596
  logger.info("Commit and push it so others can install it with: tyr --add <raw-url-to-manifest.json>");
398
597
  }
@@ -411,6 +610,10 @@ export default function modules(context: TyrContext) {
411
610
  await addModule(context, args[1], args[2]);
412
611
  return;
413
612
  }
613
+ case 'del': {
614
+ await removeModule(context, args[1]);
615
+ return;
616
+ }
414
617
  case 'manifest': {
415
618
  await generateManifest(context);
416
619
  return;
@@ -435,7 +638,7 @@ export default function modules(context: TyrContext) {
435
638
  }
436
639
  default:
437
640
  logger.error(`Unknown subcommand '${sub}'.`);
438
- logger.info('Usage: tyr --modules [sync|list|manifest]');
641
+ logger.info('Usage: tyr --modules [sync|list|manifest|del <module-name>]');
439
642
  }
440
643
  };
441
644
  }