@orxataguy/tyr 1.0.42 → 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.42",
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 {
@@ -41,8 +48,19 @@ function isValidHttpsUrl(value: string): boolean {
41
48
  }
42
49
  }
43
50
 
51
+ // Allows namespaced command names like 'db:migrate', but not a leading/
52
+ // trailing/doubled ':' (e.g. ':foo', 'foo:', 'a::b'), since those would
53
+ // produce an empty segment.
44
54
  function isValidCommandName(name: string): boolean {
45
- return /^[a-zA-Z0-9_-]+$/.test(name);
55
+ return /^[a-zA-Z0-9_-]+(:[a-zA-Z0-9_-]+)*$/.test(name);
56
+ }
57
+
58
+ // ':' is a valid character in a command name (namespacing, e.g. 'db:migrate')
59
+ // but isn't a safe filename component on every platform (Windows reserves it).
60
+ // Only used when deriving the on-disk file name — the real name with its
61
+ // colons is still what's stored in map.yml, the lockfile, and the manifest.
62
+ function toSafeFilename(commandName: string): string {
63
+ return commandName.replace(/:/g, '-');
46
64
  }
47
65
 
48
66
  function slugFromUrl(url: string): string {
@@ -71,12 +89,24 @@ async function saveYaml(fs: any, filePath: string, data: unknown): Promise<void>
71
89
  await fs.write(filePath, yaml.dump(data, { indent: 2, lineWidth: -1 }));
72
90
  }
73
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
+
74
102
  /**
75
103
  * Fetches a manifest.json (name -> raw file URL) and validates its shape.
76
104
  * Only https URLs and safe command names are accepted, to avoid path
77
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.
78
108
  */
79
- async function fetchManifest(web: any, manifestUrl: string): Promise<Record<string, string>> {
109
+ async function fetchManifest(web: any, manifestUrl: string): Promise<ParsedManifest> {
80
110
  if (!isValidHttpsUrl(manifestUrl)) {
81
111
  throw new TyrError(`Manifest URL must use https: ${manifestUrl}`);
82
112
  }
@@ -88,16 +118,28 @@ async function fetchManifest(web: any, manifestUrl: string): Promise<Record<stri
88
118
  throw new TyrError(`Manifest at ${manifestUrl} is not a valid JSON object.`);
89
119
  }
90
120
 
91
- 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
+
92
133
  if (!isValidCommandName(name)) {
93
134
  throw new TyrError(`Manifest at ${manifestUrl} has an invalid command name: '${name}'`);
94
135
  }
95
- if (typeof fileUrl !== 'string' || !isValidHttpsUrl(fileUrl)) {
136
+ if (typeof value !== 'string' || !isValidHttpsUrl(value)) {
96
137
  throw new TyrError(`Manifest at ${manifestUrl} has an invalid (non-https) URL for command '${name}'.`);
97
138
  }
139
+ commands[name] = value;
98
140
  }
99
141
 
100
- return parsed as Record<string, string>;
142
+ return { commands, envUrl };
101
143
  }
102
144
 
103
145
  /**
@@ -134,6 +176,7 @@ export async function syncModules(context: TyrContext, options: { force?: boolea
134
176
 
135
177
  if (!mapConfig.commands) mapConfig.commands = {};
136
178
  if (!lock.commands) lock.commands = {};
179
+ if (!lock.env) lock.env = {};
137
180
 
138
181
  // Snapshot the state as it was *before* this run. Collisions between two
139
182
  // modules processed in the same run are resolved by processing order
@@ -149,9 +192,9 @@ export async function syncModules(context: TyrContext, options: { force?: boolea
149
192
  for (const [moduleName, manifestUrl] of Object.entries(modulesFile.modules ?? {})) {
150
193
  logger.info(`Syncing module '${moduleName}' (${manifestUrl})...`);
151
194
 
152
- let manifest: Record<string, string>;
195
+ let parsedManifest: ParsedManifest;
153
196
  try {
154
- manifest = await fetchManifest(web, manifestUrl);
197
+ parsedManifest = await fetchManifest(web, manifestUrl);
155
198
  } catch (e) {
156
199
  const reason = e instanceof Error ? e.message : String(e);
157
200
  logger.error(`Could not read manifest for module '${moduleName}': ${reason}`);
@@ -162,6 +205,8 @@ export async function syncModules(context: TyrContext, options: { force?: boolea
162
205
  continue;
163
206
  }
164
207
 
208
+ const { commands: manifest, envUrl } = parsedManifest;
209
+
165
210
  for (const [commandName, fileUrl] of Object.entries(manifest)) {
166
211
  const alreadyInMap = !!mapSnapshot[commandName];
167
212
  const managed = lockSnapshot[commandName];
@@ -183,14 +228,15 @@ export async function syncModules(context: TyrContext, options: { force?: boolea
183
228
  const content = await web.get(fileUrl);
184
229
  const fileContent = typeof content === 'string' ? content : JSON.stringify(content, null, 2);
185
230
 
186
- const destPath = path.join(userRoot, 'commands', `${commandName}.tyr.ts`);
231
+ const fileName = toSafeFilename(commandName);
232
+ const destPath = path.join(userRoot, 'commands', `${fileName}.tyr.ts`);
187
233
  await fs.write(destPath, fileContent);
188
234
 
189
235
  if (managed && managed.module !== moduleName) {
190
236
  logger.warn(`'${commandName}' was previously managed by module '${managed.module}'; now owned by '${moduleName}'.`);
191
237
  }
192
238
 
193
- mapConfig.commands[commandName] = `./commands/${commandName}.tyr.ts`;
239
+ mapConfig.commands[commandName] = `./commands/${fileName}.tyr.ts`;
194
240
  lock.commands[commandName] = { module: moduleName, manifest: manifestUrl, source: fileUrl };
195
241
  mapChanged = true;
196
242
  lockChanged = true;
@@ -209,6 +255,39 @@ export async function syncModules(context: TyrContext, options: { force?: boolea
209
255
  summary.failed.push({ command: commandName, reason });
210
256
  }
211
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
+ }
212
291
  }
213
292
 
214
293
  if (mapChanged) await saveYaml(fs, mapPath, mapConfig);
@@ -275,6 +354,104 @@ export async function addModule(context: TyrContext, manifestUrl: string, name?:
275
354
  await syncModules(context);
276
355
  }
277
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
+
278
455
  interface GithubRepoInfo {
279
456
  owner: string;
280
457
  repo: string;
@@ -306,6 +483,10 @@ function parseGithubRemote(remoteUrl: string): GithubRepoInfo | null {
306
483
  * the publishing counterpart of `tyr --add`: whatever this produces can be
307
484
  * handed to someone else, who registers it with `tyr --add <url>`.
308
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
+ *
309
490
  * Requires ~/.tyr to be a git repository linked to a GitHub remote — there's
310
491
  * no way to build a raw.githubusercontent.com URL otherwise.
311
492
  */
@@ -371,16 +552,28 @@ export async function generateManifest(context: TyrContext): Promise<void> {
371
552
  manifest[name] = `https://raw.githubusercontent.com/${repoInfo.owner}/${repoInfo.repo}/${branch}/${normalized}`;
372
553
  }
373
554
 
374
- if (Object.keys(manifest).length === 0) {
555
+ const commandCount = Object.keys(manifest).length;
556
+ if (commandCount === 0) {
375
557
  logger.info('No commands to include in manifest.json.');
376
558
  }
377
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
+
378
570
  const manifestPath = path.join(userRoot, 'manifest.json');
379
571
  await fs.write(manifestPath, JSON.stringify(manifest, null, 2) + '\n');
380
572
 
381
573
  logger.success(
382
- `manifest.json generated at ${manifestPath} (${Object.keys(manifest).length} commands` +
383
- `${skipped.length ? `, ${skipped.length} skipped` : ''}).`
574
+ `manifest.json generated at ${manifestPath} (${commandCount} commands` +
575
+ `${skipped.length ? `, ${skipped.length} skipped` : ''}` +
576
+ `${includedEnv ? ', includes .env.example' : ''}).`
384
577
  );
385
578
  logger.info("Commit and push it so others can install it with: tyr --add <raw-url-to-manifest.json>");
386
579
  }
@@ -399,6 +592,10 @@ export default function modules(context: TyrContext) {
399
592
  await addModule(context, args[1], args[2]);
400
593
  return;
401
594
  }
595
+ case 'del': {
596
+ await removeModule(context, args[1]);
597
+ return;
598
+ }
402
599
  case 'manifest': {
403
600
  await generateManifest(context);
404
601
  return;
@@ -423,7 +620,7 @@ export default function modules(context: TyrContext) {
423
620
  }
424
621
  default:
425
622
  logger.error(`Unknown subcommand '${sub}'.`);
426
- logger.info('Usage: tyr --modules [sync|list|manifest]');
623
+ logger.info('Usage: tyr --modules [sync|list|manifest|del <module-name>]');
427
624
  }
428
625
  };
429
626
  }