@meistrari/tela-cli 1.8.0 → 1.9.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.
@@ -145,7 +145,7 @@ async function callIngredientApi(args, _options, io) {
145
145
  const url = new URL((0, tela_pantry_1.joinUrl)(environment.url, routePath));
146
146
  for (const [key, value] of request.queryParams)
147
147
  url.searchParams.append(key, value);
148
- for (const [key, value] of Object.entries(requestHeaders(targetSession.credential))) {
148
+ for (const [key, value] of Object.entries((0, tela_pantry_1.serviceCredentialHeaders)(targetSession.credential))) {
149
149
  if (!request.headers.has(key))
150
150
  request.headers.set(key, value);
151
151
  }
@@ -368,27 +368,6 @@ async function loadIngredient(slug, flags) {
368
368
  const { client } = await (0, session_1.loadPantry)(flags);
369
369
  return { client, detail: await (0, tela_pantry_1.fetchIngredientDetail)(client, slug) };
370
370
  }
371
- /**
372
- * Only `tela service api` still builds its own headers — every Pantry route now
373
- * goes through the generated client, which sends `Authorization: Bearer` and
374
- * nothing else. The `x-data-token` branch stays because arbitrary ingredient
375
- * APIs may accept it; Pantry itself demonstrably does not.
376
- */
377
- function requestHeaders(credential) {
378
- if (credential.kind === 'data-token' && credential.dataToken) {
379
- return {
380
- 'Accept': 'application/json',
381
- 'x-data-token': credential.dataToken,
382
- };
383
- }
384
- if (credential.accessToken) {
385
- return {
386
- Accept: 'application/json',
387
- Authorization: `Bearer ${credential.accessToken}`,
388
- };
389
- }
390
- throw new Error(`Credential ${credential.kind} does not contain usable token material`);
391
- }
392
371
  async function fetchIngredientCatalog(credential, baseUrl = tela_pantry_1.DEFAULT_PANTRY_BASE_URL) {
393
372
  return await (0, tela_pantry_1.fetchIngredientCatalog)((0, tela_pantry_1.createPantryClient)(baseUrl, credential), baseUrl);
394
373
  }
@@ -1,4 +1,4 @@
1
- import type { PantrySkillManifestEntry, PantrySkillSummary } from '@meistrari/tela-pantry';
1
+ import type { PantrySkillManifestEntry, PantrySkillSummary, SkillManagerDiagnostic } from '@meistrari/tela-pantry';
2
2
  import type { InstalledSkill } from '@meistrari/tela-local-config';
3
3
  import type { CliIo, GlobalOptions } from '../output';
4
4
  export declare function runSkillsCommand(args: string[], options: GlobalOptions, io: CliIo): Promise<number>;
@@ -12,22 +12,5 @@ export declare function annotateInstalled(skills: readonly PantrySkillSummary[],
12
12
  export declare function formatSkillList(entries: readonly SkillListEntry[]): string[];
13
13
  export declare function formatManifestTree(entries: readonly PantrySkillManifestEntry[]): string[];
14
14
  export declare function formatBytes(size: number): string;
15
- /**
16
- * No slugs means every installed skill: upgrading is additive, and "all of
17
- * them" is the only reading of a bare `skills upgrade`.
18
- *
19
- * A named slug the registry does not know is refused for the whole command
20
- * rather than skipped, so a typo among several cannot leave some skills
21
- * upgraded and report success.
22
- */
23
- export declare function selectUpgradeTargets(installed: readonly InstalledSkill[], slugs: readonly string[]): InstalledSkill[];
24
- /**
25
- * Is this the same installation, rather than one that replaced it?
26
- *
27
- * `slug` and `dir` are both derived from the slug, so neither moves across a
28
- * reinstall; `installedAt` and `sha` are what actually distinguish one install
29
- * from the next.
30
- */
31
- export declare function isSameInstall(left: InstalledSkill, right: InstalledSkill): boolean;
32
- export declare function scriptFailureMessage(entry: Pick<InstalledSkill, 'slug' | 'dir'>, phase: 'install' | 'uninstall'): string;
15
+ export declare function writeSkillManagerDiagnostic(io: CliIo, diagnostic: SkillManagerDiagnostic): void;
33
16
  export {};
@@ -5,16 +5,12 @@ exports.annotateInstalled = annotateInstalled;
5
5
  exports.formatSkillList = formatSkillList;
6
6
  exports.formatManifestTree = formatManifestTree;
7
7
  exports.formatBytes = formatBytes;
8
- exports.selectUpgradeTargets = selectUpgradeTargets;
9
- exports.isSameInstall = isSameInstall;
10
- exports.scriptFailureMessage = scriptFailureMessage;
11
- const promises_1 = require("node:fs/promises");
8
+ exports.writeSkillManagerDiagnostic = writeSkillManagerDiagnostic;
12
9
  const tela_pantry_1 = require("@meistrari/tela-pantry");
13
10
  const tela_local_config_1 = require("@meistrari/tela-local-config");
14
11
  const markdown_1 = require("../markdown");
15
12
  const output_1 = require("../output");
16
13
  const session_1 = require("../session");
17
- const skills_1 = require("../skills");
18
14
  async function runSkillsCommand(args, options, io) {
19
15
  const [command, ...rest] = args;
20
16
  if (!command || command === 'help' || command === '--help' || command === '-h') {
@@ -172,18 +168,15 @@ async function installSkills(args, options, io) {
172
168
  return usageError(flags.error, io);
173
169
  if (flags.rest.length === 0)
174
170
  return usageError('Usage: tela skills install <slug...> [--force] [--no-symlink] [--ignore-scripts]', io);
175
- // Every slug is validated before any of them is installed: one bad slug
176
- // among several must stop the command before the safe ones are already on
177
- // disk, and before an unvalidated slug reaches a request path.
178
- for (const slug of flags.rest)
179
- (0, skills_1.assertSafeSlug)(slug, 'install');
180
- const pantry = await (0, session_1.loadPantry)(flags);
181
- const installed = [];
182
- for (const slug of flags.rest) {
183
- const entry = await installOne(pantry, slug, flags, io);
184
- if (entry)
185
- installed.push(entry);
186
- }
171
+ const manager = (0, tela_pantry_1.createSkillManager)({
172
+ getClient: async () => (await (0, session_1.loadPantry)(flags)).client,
173
+ onDiagnostic: diagnostic => writeSkillManagerDiagnostic(io, diagnostic),
174
+ });
175
+ const { installed } = await manager.installSkills(flags.rest, {
176
+ force: flags.force,
177
+ ignoreScripts: flags.ignoreScripts,
178
+ ...(flags.symlink ? {} : { targets: [] }),
179
+ });
187
180
  if (options.json) {
188
181
  (0, output_1.writeJson)(io, { installed });
189
182
  return 0;
@@ -192,148 +185,29 @@ async function installSkills(args, options, io) {
192
185
  (0, output_1.writeLine)(io, `Installed ${entry.slug}@${entry.sha.slice(0, 7)} to ${entry.dir}`);
193
186
  return 0;
194
187
  }
195
- async function installOne(pantry, slug, flags, io, expectedCurrent) {
196
- const roots = (0, skills_1.resolveSkillRoots)();
197
- const target = (0, skills_1.resolveInstallDir)(roots, slug);
198
- const { ingredient, snapshot } = await (0, tela_pantry_1.fetchSkillFile)(pantry.client, slug);
199
- // Verified before anything is written: the order is the security property.
200
- // Nothing is removed from disk until every file in the archive has been
201
- // checked against the manifest, so a tampered or truncated download can
202
- // never delete a working install. Downloading outside the lock also keeps
203
- // the network round-trip out of the critical section.
204
- const files = (0, tela_pantry_1.verifySnapshotFiles)((0, tela_pantry_1.parseTarGz)(await (0, tela_pantry_1.downloadSkillArchive)(snapshot.archiveUrl)), snapshot.manifest);
205
- const stagedInstalls = [];
206
- let skippedReason = null;
207
- let entry = null;
208
- try {
209
- entry = await (0, tela_local_config_1.updateInstalledSkill)(slug, async (registered) => {
210
- if (expectedCurrent && (registered === null || !isSameInstall(registered, expectedCurrent))) {
211
- skippedReason = `Skipped ${slug}: it was changed while this upgrade ran`;
212
- return registered;
213
- }
214
- // Re-checked against the registry as it stands inside the lock. Read
215
- // before acquiring it, this guard would answer for a moment that has
216
- // passed by the time the files are written.
217
- //
218
- // An upgrade needs no exemption here: the entry it is upgrading names
219
- // this very directory, so the guard already recognises it as ours.
220
- if ((0, skills_1.isUnownedInstallDir)(target, registered?.dir ?? null) && !flags.force) {
221
- throw new Error(`${target} already exists and was not installed by tela.\n`
222
- + `Run \`tela skills install ${slug} --force\` to replace it, or remove it by hand.`);
223
- }
224
- const staged = await (0, skills_1.stageSkillFiles)(target, files);
225
- stagedInstalls.push(staged);
226
- const link = flags.symlink
227
- ? await (0, skills_1.linkSkill)(roots, slug, target, flags.force)
228
- : await removeRecordedSymlinks(registered?.symlinks ?? [], target, io);
229
- if (link.skipped)
230
- (0, output_1.writeError)(io, `Skipped symlink: ${link.skipped}`);
231
- return {
232
- slug,
233
- dir: target,
234
- sha: snapshot.sha,
235
- bundle: ingredient.bundle,
236
- symlinks: link.linked,
237
- scripts: ingredient.scripts,
238
- installedAt: new Date().toISOString(),
239
- };
240
- });
241
- }
242
- catch (error) {
243
- await rollbackStagedInstall(stagedInstalls.at(-1) ?? null, error);
244
- }
245
- if (skippedReason) {
246
- (0, output_1.writeError)(io, skippedReason);
247
- return null;
248
- }
249
- for (const staged of stagedInstalls)
250
- await staged.commit();
251
- if (!entry)
252
- return null;
253
- // Outside the lock on purpose: the script is arbitrary shell from the
254
- // catalog and may run for as long as it likes, which is exactly what must
255
- // not happen while every other tela process waits on the inventory.
256
- //
257
- // That leaves a window, knowingly: a concurrent install of the same slug
258
- // can replace this directory once the lock is released, and this script
259
- // would then run against the newer install's files. Reaching it takes two
260
- // tela processes on one slug, overlapping, a skill that declares an install
261
- // script, and two snapshots whose scripts differ enough for the older to do
262
- // damage. Guarding it was tried and reverted: the check costs a registry
263
- // read on every install to catch only the case where the replacement has
264
- // already finished, and a replacement landing mid-script is undetectable
265
- // without holding the lock across arbitrary shell — which the refresh now
266
- // sustains for as long as the script runs, so every other tela process
267
- // would wait behind it instead of the lock ageing out.
268
- await runLifecycleScript(entry, 'install', flags.ignoreScripts, io);
269
- return entry;
270
- }
271
- async function removeRecordedSymlinks(symlinks, target, io) {
272
- for (const linkPath of symlinks) {
273
- const outcome = await (0, skills_1.removeManagedSymlink)(linkPath, target);
274
- if (outcome.reason)
275
- (0, output_1.writeError)(io, outcome.reason);
276
- }
277
- return { linked: [], skipped: null };
278
- }
279
- async function rollbackStagedInstall(staged, error) {
280
- if (!staged)
281
- throw error;
282
- try {
283
- await staged.rollback();
284
- }
285
- catch (cleanupError) {
286
- throw new Error(`${formatError(error)}\nCleanup failed: ${formatError(cleanupError)}`);
287
- }
288
- throw error;
289
- }
290
- /**
291
- * No slugs means every installed skill: upgrading is additive, and "all of
292
- * them" is the only reading of a bare `skills upgrade`.
293
- *
294
- * A named slug the registry does not know is refused for the whole command
295
- * rather than skipped, so a typo among several cannot leave some skills
296
- * upgraded and report success.
297
- */
298
- function selectUpgradeTargets(installed, slugs) {
299
- if (slugs.length === 0)
300
- return [...installed];
301
- const bySlug = new Map(installed.map(entry => [entry.slug, entry]));
302
- const missing = slugs.filter(slug => !bySlug.has(slug));
303
- if (missing.length > 0)
304
- throw new Error(`${missing.join(', ')} not installed by tela. Nothing to upgrade.`);
305
- return slugs.map(slug => bySlug.get(slug));
188
+ function writeSkillManagerDiagnostic(io, diagnostic) {
189
+ (0, output_1.writeError)(io, diagnostic.message);
306
190
  }
307
191
  async function upgradeSkills(args, options, io) {
308
192
  const flags = parseSkillFlags(args);
309
193
  if ('error' in flags)
310
194
  return usageError(flags.error, io);
311
- const targets = selectUpgradeTargets(await (0, tela_local_config_1.listInstalledSkills)(), flags.rest);
312
- if (targets.length === 0) {
195
+ const manager = (0, tela_pantry_1.createSkillManager)({
196
+ getClient: async () => (await (0, session_1.loadPantry)(flags)).client,
197
+ onDiagnostic: diagnostic => writeSkillManagerDiagnostic(io, diagnostic),
198
+ });
199
+ const { upgraded, unchanged } = await manager.upgradeSkills(flags.rest, {
200
+ force: flags.force,
201
+ ignoreScripts: flags.ignoreScripts,
202
+ ...(flags.symlink ? {} : { targets: [] }),
203
+ });
204
+ if (upgraded.length === 0 && unchanged.length === 0) {
313
205
  if (options.json)
314
206
  (0, output_1.writeJson)(io, { upgraded: [], unchanged: [] });
315
207
  else
316
208
  (0, output_1.writeLine)(io, 'No skills installed.');
317
209
  return 0;
318
210
  }
319
- const pantry = await (0, session_1.loadPantry)(flags);
320
- const upgraded = [];
321
- const unchanged = [];
322
- for (const entry of targets) {
323
- const { snapshot } = await (0, tela_pantry_1.fetchSkillFile)(pantry.client, entry.slug);
324
- // Same snapshot means the same bytes: reinstalling would re-run the
325
- // install script for no change.
326
- if (snapshot.sha === entry.sha) {
327
- unchanged.push(entry.slug);
328
- continue;
329
- }
330
- // `flags` as the user gave them. Synthesising `force: true` here would
331
- // reach past the install directory the upgrade owns and into the link
332
- // path, where a directory another tool put there would be deleted.
333
- const upgradedEntry = await installOne(pantry, entry.slug, flags, io, entry);
334
- if (upgradedEntry)
335
- upgraded.push(upgradedEntry);
336
- }
337
211
  if (options.json) {
338
212
  (0, output_1.writeJson)(io, { upgraded, unchanged });
339
213
  return 0;
@@ -350,57 +224,13 @@ async function uninstallSkills(args, options, io) {
350
224
  return usageError(flags.error, io);
351
225
  if (flags.rest.length === 0)
352
226
  return usageError('Usage: tela skills uninstall <slug...> [--ignore-scripts]', io);
353
- const installed = await (0, tela_local_config_1.listInstalledSkills)();
354
- const bySlug = new Map(installed.map(entry => [entry.slug, entry]));
355
- const missing = flags.rest.filter(slug => !bySlug.has(slug));
356
- // Refused before anything is deleted rather than skipped over: with several
357
- // slugs, removing the ones that resolved and ignoring a typo leaves a
358
- // partial uninstall behind a success exit code.
359
- if (missing.length > 0)
360
- return usageError(`${missing.join(', ')} not installed by tela. Nothing to remove.`, io);
361
- const removed = [];
362
- const roots = (0, skills_1.resolveSkillRoots)();
363
- for (const slug of flags.rest) {
364
- // Registry-driven: an unregistered slug never reaches this loop, so a
365
- // recursive delete can only ever be pointed at a directory tela
366
- // recorded writing.
367
- const entry = bySlug.get(slug);
368
- if (!entry)
369
- continue;
370
- assertManagedInstallDir(slug, entry, roots);
371
- // Before the lock: the script is arbitrary shell, it runs in the
372
- // directory about to be deleted, and holding the inventory for its
373
- // duration would block every other tela process.
374
- await runLifecycleScript(entry, 'uninstall', flags.ignoreScripts, io);
375
- let gone = false;
376
- await (0, tela_local_config_1.updateInstalledSkill)(slug, async (registered) => {
377
- // The entry read before the script ran may be stale: a concurrent
378
- // uninstall already finished, or a concurrent install replaced it.
379
- if (registered === null)
380
- return null;
381
- // Compared whole rather than by `dir`, which is derived from the
382
- // slug and so is the same string for a reinstall — it cannot tell
383
- // "still the entry whose script just ran" from "someone reinstalled
384
- // while it ran". A reinstall carries a fresh `installedAt`, and an
385
- // entry matching in every field describes the same sha recorded at
386
- // the same instant, which this uninstall is entitled to remove.
387
- if (!isSameInstall(registered, entry)) {
388
- (0, output_1.writeError)(io, `Skipped ${slug}: it was reinstalled while this uninstall ran`);
389
- return registered;
390
- }
391
- assertManagedInstallDir(slug, registered, roots);
392
- for (const linkPath of registered.symlinks) {
393
- const outcome = await (0, skills_1.removeManagedSymlink)(linkPath, registered.dir);
394
- if (outcome.reason)
395
- (0, output_1.writeError)(io, outcome.reason);
396
- }
397
- await (0, promises_1.rm)(registered.dir, { recursive: true, force: true });
398
- gone = true;
399
- return null;
400
- });
401
- if (gone)
402
- removed.push(slug);
403
- }
227
+ const manager = (0, tela_pantry_1.createSkillManager)({
228
+ getClient: async () => (await (0, session_1.loadPantry)(flags)).client,
229
+ onDiagnostic: diagnostic => writeSkillManagerDiagnostic(io, diagnostic),
230
+ });
231
+ const { removed } = await manager.uninstallSkills(flags.rest, {
232
+ ignoreScripts: flags.ignoreScripts,
233
+ });
404
234
  if (options.json) {
405
235
  (0, output_1.writeJson)(io, { removed });
406
236
  return 0;
@@ -409,58 +239,6 @@ async function uninstallSkills(args, options, io) {
409
239
  (0, output_1.writeLine)(io, `Removed ${slug}`);
410
240
  return 0;
411
241
  }
412
- function assertManagedInstallDir(slug, entry, roots) {
413
- const expectedDir = (0, skills_1.resolveInstallDir)(roots, slug);
414
- if (entry.slug !== slug || entry.dir !== expectedDir) {
415
- throw new Error(`${slug} is recorded at ${entry.dir}, not the managed install directory ${expectedDir}. `
416
- + 'Refusing to remove it.');
417
- }
418
- }
419
- /**
420
- * Is this the same installation, rather than one that replaced it?
421
- *
422
- * `slug` and `dir` are both derived from the slug, so neither moves across a
423
- * reinstall; `installedAt` and `sha` are what actually distinguish one install
424
- * from the next.
425
- */
426
- function isSameInstall(left, right) {
427
- return left.installedAt === right.installedAt
428
- && left.sha === right.sha
429
- && left.dir === right.dir;
430
- }
431
- /**
432
- * Run a skill's install or uninstall script, unless asked not to.
433
- *
434
- * A failing install script does not undo the install: by the time it runs the
435
- * files are already on disk, and dropping the registry entry would strand a
436
- * directory `uninstall` could no longer be pointed at. The failure is reported
437
- * and the command exits non-zero instead.
438
- */
439
- async function runLifecycleScript(entry, phase, ignoreScripts, io) {
440
- const script = entry.scripts?.[phase];
441
- if (!script)
442
- return;
443
- if (ignoreScripts) {
444
- (0, output_1.writeError)(io, `Skipped ${phase} script for ${entry.slug}: ${script}`);
445
- return;
446
- }
447
- (0, output_1.writeError)(io, `Running ${phase} script for ${entry.slug}: ${script}`);
448
- const outcome = await (0, skills_1.runSkillScript)(script, entry.dir);
449
- if (outcome.output)
450
- (0, output_1.writeError)(io, outcome.output);
451
- if (!outcome.ok)
452
- throw new Error(scriptFailureMessage(entry, phase));
453
- }
454
- function scriptFailureMessage(entry, phase) {
455
- const opener = `${phase} script failed for ${entry.slug}.`;
456
- if (phase === 'uninstall')
457
- return `${opener}\n${entry.slug} was left in place. Re-run with --ignore-scripts to remove it anyway.`;
458
- return `${opener}\n`
459
- + `${entry.slug} was installed to ${entry.dir} and recorded, but its install script did not `
460
- + 'finish, so the skill may not work.\n'
461
- + `Remove it with \`tela skills uninstall ${entry.slug}\`, or reinstall without the script: `
462
- + `\`tela skills install ${entry.slug} --ignore-scripts\`.`;
463
- }
464
242
  function parseSkillFlags(args) {
465
243
  const flags = { force: false, symlink: true, ignoreScripts: false, rest: [] };
466
244
  for (let index = 0; index < args.length; index += 1) {