@orxataguy/tyr 1.0.43 → 1.0.44

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,11 +180,14 @@ 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/<module-name>.env.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
@@ -194,8 +197,11 @@ tyr --add https://raw.githubusercontent.com/someuser/tyr-modules/main/manifest.j
194
197
  tyr --modules # list registered modules
195
198
  tyr --modules sync # re-check all registered manifests and install anything missing
196
199
  tyr --update # git pull ~/.tyr (if linked) and force-refresh all imported commands
200
+ tyr --del my-module # unregister a module: removes its commands, map.yml entries and lock records
197
201
  ```
198
202
 
203
+ `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.
204
+
199
205
  Rules worth knowing:
200
206
 
201
207
  - 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 +225,7 @@ Commit and push it, and anyone can install your commands with `tyr --add <raw-ur
219
225
  - Requires a GitHub remote named `origin` — other hosts aren't supported, since the URLs only resolve through `raw.githubusercontent.com`.
220
226
  - 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
227
  - 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.
228
+ - If `~/.tyr/.env.example` exists, it's automatically included as `$env`, so whoever runs `tyr --add` on your manifest gets it as `<module-name>.env.example`.
222
229
 
223
230
  ### `tyr chat [directory] [--port <n>] [--split <0-1>]`
224
231
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@orxataguy/tyr",
3
- "version": "1.0.43",
3
+ "version": "1.0.44",
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 {
@@ -82,12 +89,24 @@ async function saveYaml(fs: any, filePath: string, data: unknown): Promise<void>
82
89
  await fs.write(filePath, yaml.dump(data, { indent: 2, lineWidth: -1 }));
83
90
  }
84
91
 
92
+ // Reserved manifest key for an optional .env.example reference. '$' can never
93
+ // appear in a valid command name (see isValidCommandName), so it can't collide
94
+ // with a real command.
95
+ const ENV_MANIFEST_KEY = '$env';
96
+
97
+ interface ParsedManifest {
98
+ commands: Record<string, string>;
99
+ envUrl?: string;
100
+ }
101
+
85
102
  /**
86
103
  * Fetches a manifest.json (name -> raw file URL) and validates its shape.
87
104
  * Only https URLs and safe command names are accepted, to avoid path
88
105
  * traversal and to keep imported code coming from a known-good transport.
106
+ * A manifest may also carry a reserved `$env` key pointing at a raw
107
+ * .env.example file for the module's required environment variables.
89
108
  */
90
- async function fetchManifest(web: any, manifestUrl: string): Promise<Record<string, string>> {
109
+ async function fetchManifest(web: any, manifestUrl: string): Promise<ParsedManifest> {
91
110
  if (!isValidHttpsUrl(manifestUrl)) {
92
111
  throw new TyrError(`Manifest URL must use https: ${manifestUrl}`);
93
112
  }
@@ -99,16 +118,28 @@ async function fetchManifest(web: any, manifestUrl: string): Promise<Record<stri
99
118
  throw new TyrError(`Manifest at ${manifestUrl} is not a valid JSON object.`);
100
119
  }
101
120
 
102
- for (const [name, fileUrl] of Object.entries(parsed)) {
121
+ const commands: Record<string, string> = {};
122
+ let envUrl: string | undefined;
123
+
124
+ for (const [name, value] of Object.entries(parsed)) {
125
+ if (name === ENV_MANIFEST_KEY) {
126
+ if (typeof value !== 'string' || !isValidHttpsUrl(value)) {
127
+ throw new TyrError(`Manifest at ${manifestUrl} has an invalid (non-https) URL for '${ENV_MANIFEST_KEY}'.`);
128
+ }
129
+ envUrl = value;
130
+ continue;
131
+ }
132
+
103
133
  if (!isValidCommandName(name)) {
104
134
  throw new TyrError(`Manifest at ${manifestUrl} has an invalid command name: '${name}'`);
105
135
  }
106
- if (typeof fileUrl !== 'string' || !isValidHttpsUrl(fileUrl)) {
136
+ if (typeof value !== 'string' || !isValidHttpsUrl(value)) {
107
137
  throw new TyrError(`Manifest at ${manifestUrl} has an invalid (non-https) URL for command '${name}'.`);
108
138
  }
139
+ commands[name] = value;
109
140
  }
110
141
 
111
- return parsed as Record<string, string>;
142
+ return { commands, envUrl };
112
143
  }
113
144
 
114
145
  /**
@@ -145,6 +176,7 @@ export async function syncModules(context: TyrContext, options: { force?: boolea
145
176
 
146
177
  if (!mapConfig.commands) mapConfig.commands = {};
147
178
  if (!lock.commands) lock.commands = {};
179
+ if (!lock.env) lock.env = {};
148
180
 
149
181
  // Snapshot the state as it was *before* this run. Collisions between two
150
182
  // modules processed in the same run are resolved by processing order
@@ -160,9 +192,9 @@ export async function syncModules(context: TyrContext, options: { force?: boolea
160
192
  for (const [moduleName, manifestUrl] of Object.entries(modulesFile.modules ?? {})) {
161
193
  logger.info(`Syncing module '${moduleName}' (${manifestUrl})...`);
162
194
 
163
- let manifest: Record<string, string>;
195
+ let parsedManifest: ParsedManifest;
164
196
  try {
165
- manifest = await fetchManifest(web, manifestUrl);
197
+ parsedManifest = await fetchManifest(web, manifestUrl);
166
198
  } catch (e) {
167
199
  const reason = e instanceof Error ? e.message : String(e);
168
200
  logger.error(`Could not read manifest for module '${moduleName}': ${reason}`);
@@ -173,6 +205,8 @@ export async function syncModules(context: TyrContext, options: { force?: boolea
173
205
  continue;
174
206
  }
175
207
 
208
+ const { commands: manifest, envUrl } = parsedManifest;
209
+
176
210
  for (const [commandName, fileUrl] of Object.entries(manifest)) {
177
211
  const alreadyInMap = !!mapSnapshot[commandName];
178
212
  const managed = lockSnapshot[commandName];
@@ -221,6 +255,39 @@ export async function syncModules(context: TyrContext, options: { force?: boolea
221
255
  summary.failed.push({ command: commandName, reason });
222
256
  }
223
257
  }
258
+
259
+ if (envUrl) {
260
+ const envFileName = `${moduleName}.env.example`;
261
+ const envDestPath = path.join(userRoot, envFileName);
262
+ const envManaged = lock.env![moduleName];
263
+
264
+ const shouldInstallEnv = !fs.exists(envDestPath);
265
+ const shouldForceUpdateEnv = force && !!envManaged;
266
+
267
+ if (!shouldInstallEnv && !shouldForceUpdateEnv) {
268
+ logger.info(
269
+ envManaged
270
+ ? `Skipping ${envFileName}: already installed. Use 'tyr --update' to refresh.`
271
+ : `Skipping ${envFileName}: a file with that name already exists and isn't managed by an import.`
272
+ );
273
+ } else {
274
+ try {
275
+ const envContent = await web.get(envUrl);
276
+ const envFileContent = typeof envContent === 'string' ? envContent : JSON.stringify(envContent, null, 2);
277
+ await fs.write(envDestPath, envFileContent);
278
+
279
+ lock.env![moduleName] = { manifest: manifestUrl, source: envUrl, file: envFileName };
280
+ lockChanged = true;
281
+
282
+ logger.success(`${shouldInstallEnv ? 'Installed' : 'Updated'} ${envFileName} from module '${moduleName}'.`);
283
+ } catch (e) {
284
+ const reason = e instanceof Error ? e.message : String(e);
285
+ logger.error(`Could not download ${envFileName} for module '${moduleName}': ${reason}`);
286
+ logger.info(`Could not download ${envFileName} for module '${moduleName}': ${reason}`);
287
+ summary.failed.push({ command: envFileName, reason });
288
+ }
289
+ }
290
+ }
224
291
  }
225
292
 
226
293
  if (mapChanged) await saveYaml(fs, mapPath, mapConfig);
@@ -287,6 +354,104 @@ export async function addModule(context: TyrContext, manifestUrl: string, name?:
287
354
  await syncModules(context);
288
355
  }
289
356
 
357
+ /**
358
+ * Unregisters a module: removes it from imported_modules.yaml, deletes every
359
+ * command file it's tracked as owning in the lockfile, drops those commands
360
+ * (and any aliases pointing at them) from map.yml, deletes its
361
+ * `<module>.env.example` if one was imported, and clears their lockfile
362
+ * entries. The counterpart to `tyr --add` — closes the loop.
363
+ */
364
+ export async function removeModule(context: TyrContext, moduleName: string): Promise<void> {
365
+ const { logger, fs, userRoot } = context as any;
366
+
367
+ if (!moduleName) {
368
+ logger.error('Missing module name.');
369
+ logger.info('Usage: tyr --del <module-name>');
370
+ return;
371
+ }
372
+
373
+ const modulesPath = path.join(userRoot, IMPORTED_MODULES_FILE);
374
+ const mapPath = path.join(userRoot, 'map.yml');
375
+ const lockPath = path.join(userRoot, LOCK_FILE);
376
+
377
+ const modulesFile = await loadYaml<ImportedModulesFile>(fs, modulesPath, { modules: {} });
378
+ const mapConfig = await loadYaml<TyrConfig>(fs, mapPath, { commands: {} });
379
+ const lock = await loadYaml<ModulesLock>(fs, lockPath, { commands: {} });
380
+
381
+ if (!modulesFile.modules) modulesFile.modules = {};
382
+ if (!mapConfig.commands) mapConfig.commands = {};
383
+ if (!lock.commands) lock.commands = {};
384
+ if (!lock.env) lock.env = {};
385
+
386
+ const isRegistered = !!modulesFile.modules[moduleName];
387
+ const managedCommands = Object.entries(lock.commands)
388
+ .filter(([, info]) => info.module === moduleName)
389
+ .map(([commandName]) => commandName);
390
+ const managedEnv = lock.env[moduleName];
391
+
392
+ if (!isRegistered && managedCommands.length === 0 && !managedEnv) {
393
+ logger.error(`Module '${moduleName}' is not registered and owns no commands. Nothing to remove.`);
394
+ logger.info(`Module '${moduleName}' was not found in ${IMPORTED_MODULES_FILE} or ${LOCK_FILE}.`);
395
+ return;
396
+ }
397
+
398
+ let mapChanged = false;
399
+ let lockChanged = false;
400
+
401
+ for (const commandName of managedCommands) {
402
+ const scriptPath = mapConfig.commands[commandName];
403
+ if (scriptPath) {
404
+ const absolutePath = path.isAbsolute(scriptPath) ? scriptPath : path.resolve(userRoot, scriptPath);
405
+ if (fs.exists(absolutePath)) {
406
+ await fs.delete(absolutePath);
407
+ }
408
+ delete mapConfig.commands[commandName];
409
+ mapChanged = true;
410
+ }
411
+
412
+ if (mapConfig.aliases) {
413
+ for (const [alias, target] of Object.entries(mapConfig.aliases)) {
414
+ if (target === commandName) {
415
+ delete mapConfig.aliases[alias];
416
+ mapChanged = true;
417
+ logger.info(`Alias '${alias}' removed (pointed to '${commandName}').`);
418
+ }
419
+ }
420
+ }
421
+
422
+ delete lock.commands[commandName];
423
+ lockChanged = true;
424
+
425
+ logger.success(`Removed command '${commandName}' (was managed by module '${moduleName}').`);
426
+ }
427
+
428
+ if (managedEnv) {
429
+ const envPath = path.join(userRoot, managedEnv.file);
430
+ if (fs.exists(envPath)) {
431
+ await fs.delete(envPath);
432
+ }
433
+ delete lock.env[moduleName];
434
+ lockChanged = true;
435
+ logger.success(`Removed ${managedEnv.file} (was managed by module '${moduleName}').`);
436
+ }
437
+
438
+ if (isRegistered) {
439
+ delete modulesFile.modules[moduleName];
440
+ await saveYaml(fs, modulesPath, modulesFile);
441
+ }
442
+
443
+ if (mapChanged) await saveYaml(fs, mapPath, mapConfig);
444
+ if (lockChanged) await saveYaml(fs, lockPath, lock);
445
+
446
+ const removedItems = [...managedCommands, ...(managedEnv ? [managedEnv.file] : [])];
447
+ const removedList = removedItems.length ? `: ${removedItems.join(', ')}` : '';
448
+ logger.success(
449
+ `Module '${moduleName}' removed` +
450
+ (removedItems.length ? ` — ${removedItems.length} item(s) removed${removedList}.` : ' (it had nothing managed).')
451
+ );
452
+ logger.info(`Module '${moduleName}' unlinked from ${IMPORTED_MODULES_FILE}${removedList}.`);
453
+ }
454
+
290
455
  interface GithubRepoInfo {
291
456
  owner: string;
292
457
  repo: string;
@@ -318,6 +483,10 @@ function parseGithubRemote(remoteUrl: string): GithubRepoInfo | null {
318
483
  * the publishing counterpart of `tyr --add`: whatever this produces can be
319
484
  * handed to someone else, who registers it with `tyr --add <url>`.
320
485
  *
486
+ * If ~/.tyr/.env.example exists, its raw URL is also included under the
487
+ * reserved `$env` key, so `tyr --add` downloads it as `<module>.env.example`
488
+ * for whoever installs this manifest.
489
+ *
321
490
  * Requires ~/.tyr to be a git repository linked to a GitHub remote — there's
322
491
  * no way to build a raw.githubusercontent.com URL otherwise.
323
492
  */
@@ -383,16 +552,28 @@ export async function generateManifest(context: TyrContext): Promise<void> {
383
552
  manifest[name] = `https://raw.githubusercontent.com/${repoInfo.owner}/${repoInfo.repo}/${branch}/${normalized}`;
384
553
  }
385
554
 
386
- if (Object.keys(manifest).length === 0) {
555
+ const commandCount = Object.keys(manifest).length;
556
+ if (commandCount === 0) {
387
557
  logger.info('No commands to include in manifest.json.');
388
558
  }
389
559
 
560
+ // If ~/.tyr has a .env.example, publish it too — tyr --add downloads it as
561
+ // <module>.env.example so whoever installs this manifest knows which
562
+ // environment variables these commands expect.
563
+ const envExamplePath = path.join(userRoot, '.env.example');
564
+ let includedEnv = false;
565
+ if (fs.exists(envExamplePath)) {
566
+ manifest[ENV_MANIFEST_KEY] = `https://raw.githubusercontent.com/${repoInfo.owner}/${repoInfo.repo}/${branch}/.env.example`;
567
+ includedEnv = true;
568
+ }
569
+
390
570
  const manifestPath = path.join(userRoot, 'manifest.json');
391
571
  await fs.write(manifestPath, JSON.stringify(manifest, null, 2) + '\n');
392
572
 
393
573
  logger.success(
394
- `manifest.json generated at ${manifestPath} (${Object.keys(manifest).length} commands` +
395
- `${skipped.length ? `, ${skipped.length} skipped` : ''}).`
574
+ `manifest.json generated at ${manifestPath} (${commandCount} commands` +
575
+ `${skipped.length ? `, ${skipped.length} skipped` : ''}` +
576
+ `${includedEnv ? ', includes .env.example' : ''}).`
396
577
  );
397
578
  logger.info("Commit and push it so others can install it with: tyr --add <raw-url-to-manifest.json>");
398
579
  }
@@ -411,6 +592,10 @@ export default function modules(context: TyrContext) {
411
592
  await addModule(context, args[1], args[2]);
412
593
  return;
413
594
  }
595
+ case 'del': {
596
+ await removeModule(context, args[1]);
597
+ return;
598
+ }
414
599
  case 'manifest': {
415
600
  await generateManifest(context);
416
601
  return;
@@ -435,7 +620,7 @@ export default function modules(context: TyrContext) {
435
620
  }
436
621
  default:
437
622
  logger.error(`Unknown subcommand '${sub}'.`);
438
- logger.info('Usage: tyr --modules [sync|list|manifest]');
623
+ logger.info('Usage: tyr --modules [sync|list|manifest|del <module-name>]');
439
624
  }
440
625
  };
441
626
  }