@alessandroraffa/tangyr 0.14.1 → 0.16.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.
Files changed (2) hide show
  1. package/dist/index.js +298 -205
  2. package/package.json +1 -1
package/dist/index.js CHANGED
@@ -15090,6 +15090,13 @@ function normalizeSource(declared) {
15090
15090
  "Configuration declares source.type: remote but no source.url. Set source.url to the https endpoint of your kit origin."
15091
15091
  );
15092
15092
  }
15093
+ if (source.channel !== void 0) {
15094
+ if (!/^[a-z0-9][a-z0-9-]{0,31}$/u.test(source.channel)) {
15095
+ throw new Error(
15096
+ `Configuration declares source.channel "${source.channel}", which must be lowercase alphanumeric with hyphens, 1-32 characters.`
15097
+ );
15098
+ }
15099
+ }
15093
15100
  return source;
15094
15101
  }
15095
15102
  function findConfigPath(explicitPath) {
@@ -17577,6 +17584,24 @@ import path15 from "path";
17577
17584
 
17578
17585
  // src/core/kit-reference.ts
17579
17586
  var BARE_URL = /^https?:\/\//u;
17587
+ function parseKitSelector(raw) {
17588
+ if (BARE_URL.test(raw)) {
17589
+ throw new Error(
17590
+ "A bare URL is not a kit reference. Configure source.url in tangyr.config.yaml and reference the kit as --kit <name> or --kit <name>@<version>."
17591
+ );
17592
+ }
17593
+ if (raw.length === 0) {
17594
+ throw new Error(
17595
+ "No kit was named. Pass --kit <name> to resolve the configured channel, or --kit <name>@<version> for an exact version."
17596
+ );
17597
+ }
17598
+ const separator = raw.lastIndexOf("@");
17599
+ if (separator === -1) {
17600
+ assertPathSafeComponent("name", raw);
17601
+ return { name: raw };
17602
+ }
17603
+ return parseKitReference(raw);
17604
+ }
17580
17605
  function parseKitReference(raw) {
17581
17606
  if (BARE_URL.test(raw)) {
17582
17607
  throw new Error(
@@ -23116,17 +23141,94 @@ function toConflictPolicy2(value) {
23116
23141
  import fs36 from "fs";
23117
23142
  import path36 from "path";
23118
23143
 
23144
+ // src/core/channel-state.ts
23145
+ import fs28 from "fs";
23146
+ import path26 from "path";
23147
+ var CHANNEL_STATE_FILENAME = ".tangyr-channels.json";
23148
+ function stateFilePath(cacheRoot) {
23149
+ return path26.join(cacheRoot, CHANNEL_STATE_FILENAME);
23150
+ }
23151
+ function channelKey(kit, channel) {
23152
+ return `${kit}/${channel}`;
23153
+ }
23154
+ function readState(cacheRoot) {
23155
+ const filePath = stateFilePath(cacheRoot);
23156
+ let raw;
23157
+ try {
23158
+ raw = fs28.readFileSync(filePath, "utf8");
23159
+ } catch (err) {
23160
+ if (err.code === "ENOENT") {
23161
+ return { state: {} };
23162
+ }
23163
+ return {
23164
+ state: {},
23165
+ warning: `Could not read ${filePath} (${err.message}); the channel downgrade guard starts from nothing for this run.`
23166
+ };
23167
+ }
23168
+ let parsed;
23169
+ try {
23170
+ parsed = JSON.parse(raw);
23171
+ } catch {
23172
+ return {
23173
+ state: {},
23174
+ warning: `${filePath} is not valid JSON; the channel downgrade guard starts from nothing for this run.`
23175
+ };
23176
+ }
23177
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
23178
+ return {
23179
+ state: {},
23180
+ warning: `${filePath} does not contain an object; the channel downgrade guard starts from nothing for this run.`
23181
+ };
23182
+ }
23183
+ return { state: parsed };
23184
+ }
23185
+ function readChannelSequence(cacheRoot, originUrl, kit, channel) {
23186
+ const { state, warning } = readState(cacheRoot);
23187
+ const record = state[originUrl]?.[channelKey(kit, channel)];
23188
+ const usable = record !== void 0 && Number.isSafeInteger(record.sequence) && typeof record.version === "string" && record.version.length > 0;
23189
+ const result = usable ? { sequence: record.sequence, version: record.version } : { sequence: void 0, version: void 0 };
23190
+ return warning === void 0 ? result : { ...result, warning };
23191
+ }
23192
+ function writeChannelSequence(params) {
23193
+ const { cacheRoot, originUrl, kit, channel, sequence, version, now } = params;
23194
+ const { state } = readState(cacheRoot);
23195
+ const key = channelKey(kit, channel);
23196
+ const existing = state[originUrl]?.[key]?.sequence;
23197
+ if (existing !== void 0 && existing > sequence) {
23198
+ return;
23199
+ }
23200
+ state[originUrl] = {
23201
+ ...state[originUrl],
23202
+ [key]: { sequence, version, seenAt: now.toISOString() }
23203
+ };
23204
+ const filePath = stateFilePath(cacheRoot);
23205
+ fs28.mkdirSync(path26.dirname(filePath), { recursive: true, mode: 448 });
23206
+ const tempPath = `${filePath}.${process.pid}.tmp`;
23207
+ fs28.writeFileSync(tempPath, `${JSON.stringify(state, null, 2)}
23208
+ `, {
23209
+ mode: 384
23210
+ });
23211
+ fs28.renameSync(tempPath, filePath);
23212
+ }
23213
+ function assertChannelNotRolledBack(params) {
23214
+ const { originUrl, kit, channel, seen, seenVersion, incoming } = params;
23215
+ if (seen !== void 0 && incoming < seen) {
23216
+ throw new IntegrityError(
23217
+ `Channel pointer for ${kit}/${channel} from ${originUrl} carries sequence ${incoming}, behind the highest already accepted (${seen}${seenVersion === void 0 ? "" : `, naming ${kit}@${seenVersion}`}). That is a replay of a superseded pointer, which would pin this machine to ${kit}@${params.incomingVersion} and hide every version published since \u2014 refusing.`
23218
+ );
23219
+ }
23220
+ }
23221
+
23222
+ // src/core/bundle.ts
23223
+ var CHANNEL_POINTER_TYPE = "tangyr.channel-pointer.v1";
23224
+ var DEFAULT_CHANNEL = "stable";
23225
+
23119
23226
  // src/core/materialize.ts
23120
23227
  import fs29 from "fs";
23121
23228
  import path27 from "path";
23122
23229
 
23123
23230
  // src/core/integrity.ts
23124
23231
  import crypto3 from "crypto";
23125
-
23126
- // src/core/bundle.ts
23127
- var DENY_LIST_TYPE = "tangyr.deny-list.v2";
23128
-
23129
- // src/core/integrity.ts
23130
23232
  function canonicalize(value) {
23131
23233
  if (Array.isArray(value)) {
23132
23234
  return value.map(canonicalize);
@@ -23241,43 +23343,7 @@ function verifyBundle(bundle, keys, expectedIdentity, now) {
23241
23343
  verifyBundleSignature(bundle, keys, now);
23242
23344
  verifyIdentity(bundle.manifest, expectedIdentity);
23243
23345
  }
23244
- function verifyDenyList(denyList, keys, now) {
23245
- if (denyList === null || typeof denyList !== "object") {
23246
- throw new IntegrityError(DENY_LIST_MALFORMED);
23247
- }
23248
- const actualKeys = Object.keys(denyList).sort();
23249
- const expectedKeys = [
23250
- "entries",
23251
- "expiresAt",
23252
- "issuedAt",
23253
- "keyId",
23254
- "sequence",
23255
- "signature",
23256
- "type"
23257
- ];
23258
- if (actualKeys.length !== expectedKeys.length || actualKeys.some((key, index) => key !== expectedKeys[index])) {
23259
- throw new IntegrityError(
23260
- `${DENY_LIST_MALFORMED} Expected exactly [${expectedKeys.join(", ")}], got [${actualKeys.join(", ")}].`
23261
- );
23262
- }
23263
- const list = denyList;
23264
- const shapeOk = list.type === DENY_LIST_TYPE && typeof list.keyId === "string" && typeof list.signature === "string" && list.signature.length > 0 && Number.isSafeInteger(list.sequence) && list.sequence >= 0 && typeof list.issuedAt === "string" && typeof list.expiresAt === "string" && Array.isArray(list.entries) && list.entries.every((entry) => typeof entry === "string");
23265
- if (!shapeOk) {
23266
- throw new IntegrityError(DENY_LIST_MALFORMED);
23267
- }
23268
- const { signature, ...signed } = list;
23269
- verifySignedPayload(
23270
- canonicalJsonBytes(signed),
23271
- signature,
23272
- list.keyId,
23273
- keys,
23274
- now
23275
- );
23276
- assertFreshnessWindow(list.issuedAt, list.expiresAt, now);
23277
- return list;
23278
- }
23279
- var DENY_LIST_MALFORMED = "Revocation deny-list response is malformed, unsigned, or corrupted \u2014 treated as an integrity failure, never as an absent deny-list (REQ-INT-009).";
23280
- function assertFreshnessWindow(issuedAt, expiresAt, now) {
23346
+ function assertFreshnessWindow(issuedAt, expiresAt, now, expiryConsequence) {
23281
23347
  const utcDesignatorPattern = /Z|[+-]\d{2}:\d{2}$/;
23282
23348
  if (!utcDesignatorPattern.test(issuedAt) || !utcDesignatorPattern.test(expiresAt)) {
23283
23349
  throw new IntegrityError(
@@ -23298,7 +23364,7 @@ function assertFreshnessWindow(issuedAt, expiresAt, now) {
23298
23364
  }
23299
23365
  if (now.getTime() > expires.getTime()) {
23300
23366
  throw new IntegrityError(
23301
- `Signed document expired at ${expiresAt} (now ${now.toISOString()}). An expired revocation deny-list is not evidence that nothing is revoked \u2014 the publisher must re-sign and republish it.`
23367
+ `Signed document expired at ${expiresAt} (now ${now.toISOString()}). ${expiryConsequence}`
23302
23368
  );
23303
23369
  }
23304
23370
  if (now.getTime() < issued.getTime()) {
@@ -23307,74 +23373,54 @@ function assertFreshnessWindow(issuedAt, expiresAt, now) {
23307
23373
  );
23308
23374
  }
23309
23375
  }
23310
-
23311
- // src/core/revocation-state.ts
23312
- import fs28 from "fs";
23313
- import path26 from "path";
23314
- var REVOCATION_STATE_FILENAME = ".tangyr-revocation.json";
23315
- function stateFilePath(cacheRoot) {
23316
- return path26.join(cacheRoot, REVOCATION_STATE_FILENAME);
23317
- }
23318
- function readState(cacheRoot) {
23319
- const filePath = stateFilePath(cacheRoot);
23320
- let raw;
23321
- try {
23322
- raw = fs28.readFileSync(filePath, "utf8");
23323
- } catch (err) {
23324
- if (err.code === "ENOENT") {
23325
- return { state: {} };
23326
- }
23327
- return {
23328
- state: {},
23329
- warning: `Could not read ${filePath} (${err.message}); the deny-list replay guard starts from nothing for this run.`
23330
- };
23331
- }
23332
- let parsed;
23333
- try {
23334
- parsed = JSON.parse(raw);
23335
- } catch {
23336
- return {
23337
- state: {},
23338
- warning: `${filePath} is not valid JSON; the deny-list replay guard starts from nothing for this run.`
23339
- };
23376
+ var CHANNEL_POINTER_MALFORMED = "Channel pointer from the origin is malformed.";
23377
+ function verifyChannelPointer(pointer, expectedKit, expectedChannel, keys, now) {
23378
+ if (pointer === null || typeof pointer !== "object") {
23379
+ throw new IntegrityError(CHANNEL_POINTER_MALFORMED);
23340
23380
  }
23341
- if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
23342
- return {
23343
- state: {},
23344
- warning: `${filePath} does not contain an object; the deny-list replay guard starts from nothing for this run.`
23345
- };
23381
+ const actualKeys = Object.keys(pointer).sort();
23382
+ const expectedKeys = [
23383
+ "channel",
23384
+ "expiresAt",
23385
+ "issuedAt",
23386
+ "keyId",
23387
+ "kit",
23388
+ "sequence",
23389
+ "signature",
23390
+ "type",
23391
+ "version"
23392
+ ];
23393
+ if (actualKeys.length !== expectedKeys.length || actualKeys.some((key, index) => key !== expectedKeys[index])) {
23394
+ throw new IntegrityError(
23395
+ `${CHANNEL_POINTER_MALFORMED} Expected exactly [${expectedKeys.join(", ")}], got [${actualKeys.join(", ")}].`
23396
+ );
23346
23397
  }
23347
- return { state: parsed };
23348
- }
23349
- function readRevocationSequence(cacheRoot, originUrl) {
23350
- const { state, warning } = readState(cacheRoot);
23351
- const record = state[originUrl];
23352
- const sequence = record !== void 0 && Number.isSafeInteger(record.sequence) ? record.sequence : void 0;
23353
- return warning === void 0 ? { sequence } : { sequence, warning };
23354
- }
23355
- function writeRevocationSequence(cacheRoot, originUrl, sequence, now) {
23356
- const { state } = readState(cacheRoot);
23357
- const existing = state[originUrl]?.sequence;
23358
- if (existing !== void 0 && existing >= sequence) {
23359
- return;
23398
+ const candidate = pointer;
23399
+ const shapeOk = candidate.type === CHANNEL_POINTER_TYPE && typeof candidate.keyId === "string" && typeof candidate.kit === "string" && candidate.kit.length > 0 && typeof candidate.channel === "string" && candidate.channel.length > 0 && typeof candidate.version === "string" && candidate.version.length > 0 && typeof candidate.signature === "string" && candidate.signature.length > 0 && Number.isSafeInteger(candidate.sequence) && candidate.sequence >= 0 && typeof candidate.issuedAt === "string" && typeof candidate.expiresAt === "string";
23400
+ if (!shapeOk) {
23401
+ throw new IntegrityError(CHANNEL_POINTER_MALFORMED);
23360
23402
  }
23361
- state[originUrl] = { sequence, seenAt: now.toISOString() };
23362
- const filePath = stateFilePath(cacheRoot);
23363
- fs28.mkdirSync(path26.dirname(filePath), { recursive: true, mode: 448 });
23364
- const tempPath = `${filePath}.${process.pid}.tmp`;
23365
- fs28.writeFileSync(tempPath, `${JSON.stringify(state, null, 2)}
23366
- `, {
23367
- mode: 384
23368
- });
23369
- fs28.renameSync(tempPath, filePath);
23370
- }
23371
- function assertSequenceNotReplayed(params) {
23372
- const { originUrl, seen, incoming } = params;
23373
- if (seen !== void 0 && incoming < seen) {
23403
+ const { signature, ...signed } = candidate;
23404
+ verifySignedPayload(
23405
+ canonicalJsonBytes(signed),
23406
+ signature,
23407
+ candidate.keyId,
23408
+ keys,
23409
+ now
23410
+ );
23411
+ if (candidate.kit !== expectedKit || candidate.channel !== expectedChannel) {
23374
23412
  throw new IntegrityError(
23375
- `Revocation deny-list from ${originUrl} carries sequence ${incoming}, behind the highest already accepted (${seen}). That is a replay of a superseded deny-list, which would silently un-revoke a kit \u2014 refusing (REQ-INT-009).`
23413
+ `Channel pointer is signed for ${candidate.kit}/${candidate.channel} but was served as ${expectedKit}/${expectedChannel}. A pointer the publisher signed for one channel is being presented as another's \u2014 refusing.`
23376
23414
  );
23377
23415
  }
23416
+ assertPathSafeComponent("version", candidate.version);
23417
+ assertFreshnessWindow(
23418
+ candidate.issuedAt,
23419
+ candidate.expiresAt,
23420
+ now,
23421
+ "An expired channel pointer is not evidence that it still names the newest version \u2014 the publisher must re-sign and republish it."
23422
+ );
23423
+ return candidate;
23378
23424
  }
23379
23425
 
23380
23426
  // src/core/materialize.ts
@@ -23422,7 +23468,6 @@ async function withBoundedRetry(operation, sleep2, logger) {
23422
23468
  );
23423
23469
  }
23424
23470
  var BUNDLE_OBJECT_PATH_PREFIX = "/v1/kits";
23425
- var DENY_LIST_OBJECT_PATH = "/v1/deny-list";
23426
23471
  async function fetchVerifiedBundle(params) {
23427
23472
  const { originUrl, reference, transport, keys, now, logger, sleep: sleep2 } = params;
23428
23473
  const credentials = resolveCredentialNonInteractive();
@@ -23476,76 +23521,6 @@ async function fetchVerifiedBundle(params) {
23476
23521
  }
23477
23522
  return parsed;
23478
23523
  }
23479
- async function assertReferenceNotRevoked(params) {
23480
- const {
23481
- originUrl,
23482
- reference,
23483
- cacheRoot,
23484
- transport,
23485
- keys,
23486
- now,
23487
- logger,
23488
- sleep: sleep2
23489
- } = params;
23490
- const credentials = resolveCredentialNonInteractive();
23491
- const denyList = await withBoundedRetry(
23492
- async () => {
23493
- const controller = new AbortController();
23494
- const response = await transport.fetchObjectStream(
23495
- originUrl,
23496
- DENY_LIST_OBJECT_PATH,
23497
- credentials,
23498
- controller.signal
23499
- );
23500
- if (response.status === 401 || response.status === 403) {
23501
- throw new AuthenticationError(
23502
- `The origin rejected the credential (HTTP ${response.status}) fetching the revocation deny-list. Set TANGYR_ACCESS_CLIENT_ID and TANGYR_ACCESS_CLIENT_SECRET, or run 'tangyr auth login'.`
23503
- );
23504
- }
23505
- if (response.status !== 200) {
23506
- throw new Error(
23507
- `Unexpected HTTP ${response.status} fetching the revocation deny-list from ${originUrl}${DENY_LIST_OBJECT_PATH}.`
23508
- );
23509
- }
23510
- const buffer = await readBoundedStream(
23511
- response.body,
23512
- MAX_TOTAL_BUNDLE_BYTES,
23513
- () => controller.abort()
23514
- );
23515
- let parsed;
23516
- try {
23517
- parsed = JSON.parse(buffer.toString("utf8"));
23518
- } catch {
23519
- throw new IntegrityError(
23520
- "Revocation deny-list response is not valid JSON \u2014 rejecting (REQ-INT-009)."
23521
- );
23522
- }
23523
- return verifyDenyList(parsed, keys, now);
23524
- },
23525
- sleep2,
23526
- logger
23527
- );
23528
- const seen = readRevocationSequence(cacheRoot, originUrl);
23529
- if (seen.warning) {
23530
- logger.warn(seen.warning);
23531
- }
23532
- assertSequenceNotReplayed({
23533
- originUrl,
23534
- seen: seen.sequence,
23535
- incoming: denyList.sequence
23536
- });
23537
- writeRevocationSequence(cacheRoot, originUrl, denyList.sequence, now);
23538
- const key = `${reference.name}@${reference.version}`;
23539
- if (denyList.entries.includes(key)) {
23540
- const entryDir = cacheEntryPath(cacheRoot, reference);
23541
- if (fs29.existsSync(entryDir)) {
23542
- fs29.rmSync(entryDir, { recursive: true, force: true });
23543
- }
23544
- throw new IntegrityError(
23545
- `"${key}" is present on the signed revocation deny-list \u2014 refusing to materialize or install; any existing cache entry has been invalidated (REQ-INT-009).`
23546
- );
23547
- }
23548
- }
23549
23524
  function parseVersionTriple(version) {
23550
23525
  const leading = version.split("-")[0];
23551
23526
  const parts = leading.split(".");
@@ -23716,16 +23691,6 @@ async function materializeRemoteKit(params) {
23716
23691
  logger,
23717
23692
  sleep: sleep2
23718
23693
  });
23719
- await assertReferenceNotRevoked({
23720
- originUrl,
23721
- reference,
23722
- cacheRoot,
23723
- transport,
23724
- keys,
23725
- now,
23726
- logger,
23727
- sleep: sleep2
23728
- });
23729
23694
  assertCliVersionCompatible(bundle.manifest.minCliVersion, CLI_VERSION);
23730
23695
  const decoded = verifyBundleFileHashes(bundle);
23731
23696
  if (decoded.has(CACHE_COMPLETION_MARKER) || decoded.has(BUNDLE_DOCUMENT_FILENAME)) {
@@ -23752,6 +23717,91 @@ async function materializeRemoteKit(params) {
23752
23717
  throw error;
23753
23718
  }
23754
23719
  }
23720
+ var CHANNEL_OBJECT_PATH_PREFIX = "/v1/channels";
23721
+ var MAX_CHANNEL_POINTER_BYTES = 64 * 1024;
23722
+ async function resolveChannelVersion(params) {
23723
+ const {
23724
+ originUrl,
23725
+ kit,
23726
+ channel,
23727
+ cacheRoot,
23728
+ transport,
23729
+ keys,
23730
+ now,
23731
+ logger,
23732
+ sleep: sleep2
23733
+ } = params;
23734
+ const credentials = resolveCredentialNonInteractive();
23735
+ const objectPath = `${CHANNEL_OBJECT_PATH_PREFIX}/${kit}/${channel}.json`;
23736
+ const pointer = await withBoundedRetry(
23737
+ async () => {
23738
+ const controller = new AbortController();
23739
+ const response = await transport.fetchObjectStream(
23740
+ originUrl,
23741
+ objectPath,
23742
+ credentials,
23743
+ controller.signal
23744
+ );
23745
+ if (response.status === 401 || response.status === 403) {
23746
+ throw new AuthenticationError(
23747
+ `The origin rejected the credential (HTTP ${response.status}) resolving ${kit}/${channel}. Set TANGYR_ACCESS_CLIENT_ID and TANGYR_ACCESS_CLIENT_SECRET, or run 'tangyr auth login'.`
23748
+ );
23749
+ }
23750
+ if (response.status === 404) {
23751
+ throw new IntegrityError(
23752
+ `The origin has no ${channel} channel for "${kit}" (HTTP 404 at ${originUrl}${objectPath}). Either the publisher has not published that channel, or the kit name is wrong. Reference an explicit version as --kit ${kit}@<version> if you know one.`
23753
+ );
23754
+ }
23755
+ if (response.status !== 200) {
23756
+ throw new Error(
23757
+ `Unexpected HTTP ${response.status} resolving ${kit}/${channel} from ${originUrl}${objectPath}.`
23758
+ );
23759
+ }
23760
+ const buffer = await readBoundedStream(
23761
+ response.body,
23762
+ MAX_CHANNEL_POINTER_BYTES,
23763
+ () => controller.abort()
23764
+ );
23765
+ let parsed;
23766
+ try {
23767
+ parsed = JSON.parse(buffer.toString("utf8"));
23768
+ } catch {
23769
+ throw new IntegrityError(
23770
+ `Channel pointer for ${kit}/${channel} is not valid JSON \u2014 rejecting.`
23771
+ );
23772
+ }
23773
+ return verifyChannelPointer(parsed, kit, channel, keys, now);
23774
+ },
23775
+ sleep2,
23776
+ logger
23777
+ );
23778
+ const seen = readChannelSequence(cacheRoot, originUrl, kit, channel);
23779
+ if (seen.warning) {
23780
+ logger.warn(seen.warning);
23781
+ }
23782
+ assertChannelNotRolledBack({
23783
+ originUrl,
23784
+ kit,
23785
+ channel,
23786
+ seen: seen.sequence,
23787
+ seenVersion: seen.version,
23788
+ incoming: pointer.sequence,
23789
+ incomingVersion: pointer.version
23790
+ });
23791
+ writeChannelSequence({
23792
+ cacheRoot,
23793
+ originUrl,
23794
+ kit,
23795
+ channel,
23796
+ sequence: pointer.sequence,
23797
+ version: pointer.version,
23798
+ now
23799
+ });
23800
+ logger.verbose(
23801
+ `Resolved ${kit}/${channel} to ${kit}@${pointer.version} (pointer sequence ${pointer.sequence}).`
23802
+ );
23803
+ return pointer.version;
23804
+ }
23755
23805
 
23756
23806
  // src/core/kit-source.ts
23757
23807
  function resolveCacheOnly(entryDir, reference, keys, now) {
@@ -23790,13 +23840,25 @@ async function resolveKitSource(params) {
23790
23840
  if (config.source.type !== "remote") {
23791
23841
  return { kitName: kitOption };
23792
23842
  }
23793
- const reference = parseKitReference(kitOption ?? "");
23843
+ const selector = parseKitSelector(kitOption ?? "");
23794
23844
  if (!config.source.url) {
23795
23845
  throw new Error(
23796
23846
  "Configuration declares source.type: remote but no source.url. Set source.url to the https endpoint of your kit origin."
23797
23847
  );
23798
23848
  }
23799
23849
  const cacheRoot = resolveCacheRoot(env2);
23850
+ const reference = selector.version === void 0 ? await resolveThroughChannel({
23851
+ name: selector.name,
23852
+ channel: config.source.channel ?? DEFAULT_CHANNEL,
23853
+ originUrl: config.source.url,
23854
+ cacheRoot,
23855
+ offline: offline === true,
23856
+ transport,
23857
+ keys,
23858
+ now,
23859
+ logger,
23860
+ sleep: sleep2
23861
+ }) : { name: selector.name, version: selector.version };
23800
23862
  const entryDir = cacheEntryPath(cacheRoot, reference);
23801
23863
  if (offline) {
23802
23864
  return resolveCacheOnly(entryDir, reference, keys, now);
@@ -23827,7 +23889,7 @@ async function resolveKitSource(params) {
23827
23889
  }
23828
23890
  function degradedWarmReturn() {
23829
23891
  process.stderr.write(
23830
- `Degraded: proceeding on the warm cache for "${reference.name}@${reference.version}" after a network failure during the authorization-freshness re-check \u2014 revocation and freshness were not re-checked. Run with --refresh once connectivity is restored.
23892
+ `Degraded: proceeding on the warm cache for "${reference.name}@${reference.version}" after a network failure during the authorization-freshness re-check \u2014 freshness was not re-checked. Run with --refresh once connectivity is restored.
23831
23893
  `
23832
23894
  );
23833
23895
  return {
@@ -23869,23 +23931,6 @@ async function resolveKitSource(params) {
23869
23931
  );
23870
23932
  }
23871
23933
  if (probeOutcome.outcome === "success") {
23872
- try {
23873
- await assertReferenceNotRevoked({
23874
- originUrl: config.source.url,
23875
- reference,
23876
- cacheRoot,
23877
- transport,
23878
- keys,
23879
- now,
23880
- logger,
23881
- sleep: sleep2
23882
- });
23883
- } catch (error) {
23884
- if (!(error instanceof RemoteUnavailableError)) {
23885
- throw error;
23886
- }
23887
- return degradedWarmReturn();
23888
- }
23889
23934
  writeLastAuthorizedAt(cacheRoot, config.source.url, now);
23890
23935
  const warning = formatAdvanceExpiryWarning(
23891
23936
  probeOutcome.metadata.expiresAt,
@@ -23934,6 +23979,54 @@ async function resolveKitSource(params) {
23934
23979
  writeLastAuthorizedAt(cacheRoot, config.source.url, now);
23935
23980
  return { kitPathOverride, kitName: reference.name };
23936
23981
  }
23982
+ async function resolveThroughChannel(params) {
23983
+ const { name, channel, originUrl, cacheRoot, offline, logger } = params;
23984
+ const lastSeenVersion = () => {
23985
+ const seen = readChannelSequence(cacheRoot, originUrl, name, channel);
23986
+ if (seen.warning) {
23987
+ logger.warn(seen.warning);
23988
+ }
23989
+ return seen.version;
23990
+ };
23991
+ if (offline) {
23992
+ const version = lastSeenVersion();
23993
+ if (version === void 0) {
23994
+ throw new OfflineCacheMissError(
23995
+ `--offline cannot resolve ${name}/${channel}: this machine has never resolved that channel, and a channel pointer is never cached. Run once with network access, or name a version as --kit ${name}@<version>.`
23996
+ );
23997
+ }
23998
+ logger.warn(
23999
+ `--offline: using ${name}@${version}, the last version this machine saw on ${channel}. A newer one may exist.`
24000
+ );
24001
+ return { name, version };
24002
+ }
24003
+ try {
24004
+ const version = await resolveChannelVersion({
24005
+ originUrl,
24006
+ kit: name,
24007
+ channel,
24008
+ cacheRoot,
24009
+ transport: params.transport,
24010
+ keys: params.keys,
24011
+ now: params.now,
24012
+ logger,
24013
+ sleep: params.sleep
24014
+ });
24015
+ return { name, version };
24016
+ } catch (error) {
24017
+ if (!(error instanceof RemoteUnavailableError)) {
24018
+ throw error;
24019
+ }
24020
+ const version = lastSeenVersion();
24021
+ if (version === void 0) {
24022
+ throw error;
24023
+ }
24024
+ logger.warn(
24025
+ `Could not reach the origin to resolve ${name}/${channel}; falling back to ${name}@${version}, the last version this machine saw. A newer one may exist.`
24026
+ );
24027
+ return { name, version };
24028
+ }
24029
+ }
23937
24030
 
23938
24031
  // src/core/keys.ts
23939
24032
  var PINNED_PUBLISHER_KEYS = [
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@alessandroraffa/tangyr",
3
- "version": "0.14.1",
3
+ "version": "0.16.0",
4
4
  "description": "CLI for the Tangyr discipline — install and manage operating kits for AI coding tools",
5
5
  "license": "MIT",
6
6
  "engines": {