tines 0.0.236 → 0.0.238

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 (3) hide show
  1. package/README.md +6 -0
  2. package/dist/index.js +549 -21
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -73,6 +73,12 @@ tines workflows preview review.json --choices choices.json --plan-out review.pla
73
73
  tines workflows install review.json --plan review.plan.json --confirm sha256:<plan-digest>
74
74
  ```
75
75
 
76
+ `preview` and `install` also accept a canonical public snapshot URL. Same-instance URLs keep hosted
77
+ withdrawal checks; foreign URLs are fetched directly by the CLI without forwarding the destination
78
+ API key, cookies, proxy authorization, or referrer. A foreign-source install re-downloads the URL and
79
+ requires the exact reviewed bytes. See the
80
+ [workflow package guide](https://github.com/tbuckley/tines/blob/main/docs/workflow-packages.md).
81
+
76
82
  Workflow names are accepted only when unique; use the ID when duplicate names exist. Export
77
83
  selection is explicit: `--project`, repeatable `--schedule`, repeatable
78
84
  `--tier '<state-id>=balanced'`, `--project-routing`, and `--inputs declarations.json` add
package/dist/index.js CHANGED
@@ -4225,6 +4225,48 @@ function createApiClient(options) {
4225
4225
  getWorkflowPackageReceipt: (planId) => get(
4226
4226
  `/api/v1/library/installs/${encodeURIComponent(planId)}`
4227
4227
  ),
4228
+ validatePublication: (body) => request(
4229
+ "POST",
4230
+ "/api/v1/publications/validate",
4231
+ body
4232
+ ),
4233
+ preparePublication: (body) => request(
4234
+ "POST",
4235
+ "/api/v1/publications/prepare",
4236
+ body
4237
+ ),
4238
+ publishPublication: (candidateId, body) => request(
4239
+ "POST",
4240
+ `/api/v1/publications/${encodeURIComponent(candidateId)}/publish`,
4241
+ body
4242
+ ),
4243
+ getPublicationResult: (candidateId) => get(
4244
+ `/api/v1/publications/${encodeURIComponent(candidateId)}/result`
4245
+ ),
4246
+ listPublications: (workflow2) => get(
4247
+ `/api/v1/publications${query({ workflow: workflow2 })}`
4248
+ ),
4249
+ withdrawPublication: (snapshotId) => request(
4250
+ "POST",
4251
+ `/api/v1/publications/${encodeURIComponent(snapshotId)}/withdraw`
4252
+ ),
4253
+ restorePublication: (snapshotId) => request(
4254
+ "POST",
4255
+ `/api/v1/publications/${encodeURIComponent(snapshotId)}/restore`
4256
+ ),
4257
+ getPublicSnapshot: (snapshotId) => get(
4258
+ `/api/v1/publications/public/${encodeURIComponent(snapshotId)}`
4259
+ ),
4260
+ getPublicSnapshotStatus: (snapshotId) => get(
4261
+ `/api/v1/publications/public/${encodeURIComponent(snapshotId)}/status`
4262
+ ),
4263
+ prepareHostedWorkflowPackage: (snapshotId, choices = {}) => request(
4264
+ "POST",
4265
+ `/api/v1/publications/public/${encodeURIComponent(snapshotId)}/prepare-install`,
4266
+ { choices }
4267
+ ),
4268
+ downloadPublicSnapshot: (snapshotId) => raw("GET", `/api/v1/publications/public/${encodeURIComponent(snapshotId)}/download`),
4269
+ downloadPublicationReuseNotice: (snapshotId) => raw("GET", `/api/v1/publications/public/${encodeURIComponent(snapshotId)}/reuse.txt`),
4228
4270
  validateLibrary: (body) => request(
4229
4271
  "POST",
4230
4272
  "/api/v1/library/validate",
@@ -4516,6 +4558,15 @@ function deriveTransferConflictDeltas(before, after) {
4516
4558
  );
4517
4559
  }
4518
4560
 
4561
+ // ../shared/src/workflow-navigation.ts
4562
+ function workflowStateAnchorId(stateId) {
4563
+ return `state-${stateId}`;
4564
+ }
4565
+ function workflowStateHref(workflowId, stateId) {
4566
+ const anchor = workflowStateAnchorId(stateId);
4567
+ return `/workflows/${workflowId}?state=${stateId}#${anchor}`;
4568
+ }
4569
+
4519
4570
  // ../shared/src/library/types.ts
4520
4571
  var LIBRARY_V3_MAX_DEPTH = 64;
4521
4572
  var LIBRARY_V3_MAX_RECORDS = 1e3;
@@ -5494,6 +5545,27 @@ function parseStrictLibraryJson(source) {
5494
5545
  // ../shared/src/library/choices.ts
5495
5546
  var PACKAGE_CHOICES_MAX_BYTES = 512 * 1024;
5496
5547
 
5548
+ // ../shared/src/publications.ts
5549
+ var PUBLIC_WORKFLOW_MAX_BYTES = 1048576;
5550
+ var PUBLICATION_CANDIDATE_TTL_MS = 15 * 60 * 1e3;
5551
+ var SNAPSHOT_ID = /^[A-Za-z0-9_-]{20,100}$/;
5552
+ function parsePublicSnapshotReference(value, configuredOrigin) {
5553
+ if (SNAPSHOT_ID.test(value)) return value;
5554
+ let url;
5555
+ try {
5556
+ url = new URL(value);
5557
+ } catch {
5558
+ throw new Error("Expected a publication ID or canonical public URL");
5559
+ }
5560
+ if (!["http:", "https:"].includes(url.protocol) || url.username || url.password || url.hash)
5561
+ throw new Error("Invalid public publication URL");
5562
+ if (configuredOrigin && url.origin !== new URL(configuredOrigin).origin)
5563
+ throw new Error("external_source_requires_download");
5564
+ const match = /^\/p\/([A-Za-z0-9_-]{20,100})(?:\/download)?$/.exec(url.pathname);
5565
+ if (!match || url.search) throw new Error("Invalid public publication URL");
5566
+ return match[1];
5567
+ }
5568
+
5497
5569
  // ../shared/src/usage.ts
5498
5570
  function usageCostLabel(cost, finalized = 1) {
5499
5571
  if (finalized === 0) return "No runs";
@@ -12497,6 +12569,7 @@ function register10(program3) {
12497
12569
 
12498
12570
  // src/commands/workflows.ts
12499
12571
  import { readFileSync as readFileSync12 } from "node:fs";
12572
+ import { createInterface as createInterface5 } from "node:readline/promises";
12500
12573
 
12501
12574
  // src/workflow-packages.ts
12502
12575
  import { createInterface as createInterface4 } from "node:readline/promises";
@@ -12526,12 +12599,13 @@ function readStrictObject(path2, label) {
12526
12599
  function normalizeApiBase(value) {
12527
12600
  return new URL(value).toString().replace(/\/$/, "");
12528
12601
  }
12529
- function saveWorkflowPackagePlan(path2, apiBase, plan) {
12602
+ function saveWorkflowPackagePlan(path2, apiBase, plan, remoteSource) {
12530
12603
  const saved = {
12531
12604
  format: "tines.workflow-install-plan",
12532
12605
  version: 1,
12533
12606
  api_base: normalizeApiBase(apiBase),
12534
12607
  document_digest: plan.document_digest,
12608
+ ...remoteSource ? { remote_source: remoteSource } : {},
12535
12609
  plan
12536
12610
  };
12537
12611
  writeJsonFile(path2, saved, { secret: true });
@@ -12539,7 +12613,7 @@ function saveWorkflowPackagePlan(path2, apiBase, plan) {
12539
12613
  }
12540
12614
  function readWorkflowPackagePlan(path2) {
12541
12615
  const saved = readStrictObject(path2, "plan file");
12542
- const outerKeys = ["format", "version", "api_base", "document_digest", "plan"];
12616
+ const outerKeys = ["format", "version", "api_base", "document_digest", "remote_source", "plan"];
12543
12617
  const planKeys = [
12544
12618
  "operations",
12545
12619
  "document",
@@ -12553,9 +12627,17 @@ function readWorkflowPackagePlan(path2) {
12553
12627
  "actor_key",
12554
12628
  "compiler_version",
12555
12629
  "plan_token",
12556
- "budget"
12630
+ "budget",
12631
+ "source"
12557
12632
  ];
12558
- if (Object.keys(saved).length !== outerKeys.length || !Object.keys(saved).every((key) => outerKeys.includes(key)) || saved.format !== "tines.workflow-install-plan" || saved.version !== 1 || typeof saved.api_base !== "string" || typeof saved.document_digest !== "string" || !saved.plan || typeof saved.plan !== "object" || typeof saved.plan.plan_token !== "string" || typeof saved.plan.plan_digest !== "string" || Object.keys(saved.plan).length !== planKeys.length || !Object.keys(saved.plan).every((key) => planKeys.includes(key))) {
12633
+ if (![5, 6].includes(Object.keys(saved).length) || !Object.keys(saved).every((key) => outerKeys.includes(key)) || saved.format !== "tines.workflow-install-plan" || saved.version !== 1 || typeof saved.api_base !== "string" || typeof saved.document_digest !== "string" || !saved.plan || typeof saved.plan !== "object" || typeof saved.plan.plan_token !== "string" || typeof saved.plan.plan_digest !== "string" || ![planKeys.length - 1, planKeys.length].includes(
12634
+ Object.keys(saved.plan).length
12635
+ ) || !Object.keys(saved.plan).every((key) => planKeys.includes(key))) {
12636
+ throw new Error("invalid workflow package plan file");
12637
+ }
12638
+ if (saved.remote_source !== void 0 && (!saved.remote_source || typeof saved.remote_source !== "object" || Object.keys(saved.remote_source).length !== 2 || typeof saved.remote_source.url !== "string" || !/^sha256:[0-9a-f]{64}$/.test(
12639
+ String(saved.remote_source.bytes_sha256)
12640
+ ))) {
12559
12641
  throw new Error("invalid workflow package plan file");
12560
12642
  }
12561
12643
  const result = saved;
@@ -12578,8 +12660,13 @@ function tokenPayload(token) {
12578
12660
  }
12579
12661
  function expectedWorkflowPackageOperations(plan) {
12580
12662
  const operations = [];
12663
+ const allocatedId = (localId) => {
12664
+ const id2 = plan.allocation.records[localId]?.id;
12665
+ if (!id2) throw new Error(`saved workflow package plan has no allocation for ${localId}`);
12666
+ return id2;
12667
+ };
12581
12668
  for (const workflow2 of plan.resolved.workflows) {
12582
- const workflowId = plan.allocation.records[workflow2.id]?.id;
12669
+ const workflowId = allocatedId(workflow2.id);
12583
12670
  const href = `/workflows/${workflowId}`;
12584
12671
  operations.push({
12585
12672
  action: "create",
@@ -12590,15 +12677,17 @@ function expectedWorkflowPackageOperations(plan) {
12590
12677
  href,
12591
12678
  relationship: workflow2.id === plan.document.main_workflow_id ? "main" : "dependency"
12592
12679
  });
12593
- for (const state of workflow2.states)
12680
+ for (const state of workflow2.states) {
12681
+ const stateId = allocatedId(state.id);
12594
12682
  operations.push({
12595
12683
  action: "create",
12596
12684
  kind: "state",
12597
12685
  local_id: state.id,
12598
- id: plan.allocation.records[state.id]?.id,
12686
+ id: stateId,
12599
12687
  name: state.name,
12600
- href: `${href}#state-${plan.allocation.records[state.id]?.id}`
12688
+ href: workflowStateHref(workflowId, stateId)
12601
12689
  });
12690
+ }
12602
12691
  for (const transition of workflow2.transitions)
12603
12692
  operations.push({
12604
12693
  action: "create",
@@ -12677,7 +12766,8 @@ function assertPlanBinding(plan) {
12677
12766
  actor_key: plan.actor_key,
12678
12767
  compiler_version: plan.compiler_version,
12679
12768
  allocation: plan.allocation,
12680
- budget: plan.budget
12769
+ budget: plan.budget,
12770
+ ...plan.source ? { source: plan.source } : {}
12681
12771
  };
12682
12772
  for (const [field, value] of Object.entries(mirrors)) {
12683
12773
  const signedField = field === "id" ? "id" : field;
@@ -12686,7 +12776,19 @@ function assertPlanBinding(plan) {
12686
12776
  }
12687
12777
  if (signedDigest !== digest2 || plan.plan_digest !== digest2)
12688
12778
  throw new Error("saved workflow package plan digest does not bind its review");
12689
- if (canonicalizeLibraryValue(plan.operations) !== canonicalizeLibraryValue(expectedWorkflowPackageOperations(plan)))
12779
+ const comparisonOperations = plan.operations.map((operation) => {
12780
+ if (operation.kind !== "state") return operation;
12781
+ const workflow2 = plan.resolved.workflows.find(
12782
+ (candidate) => candidate.states.some((state) => state.id === operation.local_id)
12783
+ );
12784
+ if (!workflow2) return operation;
12785
+ const workflowId = plan.allocation.records[workflow2.id]?.id;
12786
+ const stateId = plan.allocation.records[operation.local_id]?.id;
12787
+ if (!workflowId || !stateId) return operation;
12788
+ const legacyHref = `/workflows/${workflowId}#state-${stateId}`;
12789
+ return operation.href === legacyHref ? { ...operation, href: workflowStateHref(workflowId, stateId) } : operation;
12790
+ });
12791
+ if (canonicalizeLibraryValue(comparisonOperations) !== canonicalizeLibraryValue(expectedWorkflowPackageOperations(plan)))
12690
12792
  throw new Error("saved workflow package plan has modified operations");
12691
12793
  }
12692
12794
  var lines = (heading, values) => values.length ? [heading, ...values.map((value) => ` ${value}`)] : [heading, " none"];
@@ -12794,10 +12896,16 @@ function formatValidation(result) {
12794
12896
  }
12795
12897
  async function localDocument(path2) {
12796
12898
  const raw = readPackageSource(path2);
12899
+ return packageDocument(raw);
12900
+ }
12901
+ async function packageDocument(raw) {
12797
12902
  const document = await parseLibraryV3Document(raw);
12798
12903
  if (document.profile !== "workflow") throw new Error("expected a workflow-profile package");
12799
12904
  return { raw, document };
12800
12905
  }
12906
+ function workflowPackageBytesSha256(raw) {
12907
+ return `sha256:${createHash2("sha256").update(raw).digest("hex")}`;
12908
+ }
12801
12909
  function canonicalWorkflowPackage(document) {
12802
12910
  return canonicalizeLibraryValue(document);
12803
12911
  }
@@ -12841,7 +12949,218 @@ async function recoverOrInstall(api, raw, plan) {
12841
12949
  }
12842
12950
  }
12843
12951
 
12952
+ // src/publication-fetch.ts
12953
+ import { lookup as dnsLookup } from "node:dns/promises";
12954
+ import { request as httpRequest } from "node:http";
12955
+ import { request as httpsRequest } from "node:https";
12956
+ import { isIP } from "node:net";
12957
+ var SNAPSHOT_PATH = /^\/p\/[A-Za-z0-9_-]{20,100}(?:\/download)?$/;
12958
+ var MAX_REDIRECTS = 3;
12959
+ var DEFAULT_TIMEOUT_MS = 3e4;
12960
+ function mappedIpv4(address) {
12961
+ const match = /^::ffff:(\d{1,3}(?:\.\d{1,3}){3})$/i.exec(address);
12962
+ if (match) return match[1];
12963
+ const hexadecimal = /^::ffff:([0-9a-f]{1,4}):([0-9a-f]{1,4})$/i.exec(address);
12964
+ if (!hexadecimal) return void 0;
12965
+ const high = Number.parseInt(hexadecimal[1], 16);
12966
+ const low = Number.parseInt(hexadecimal[2], 16);
12967
+ return `${high >> 8}.${high & 255}.${low >> 8}.${low & 255}`;
12968
+ }
12969
+ function isLoopback(address) {
12970
+ const mapped = mappedIpv4(address);
12971
+ if (mapped) return isLoopback(mapped);
12972
+ return address === "::1" || /^127\./.test(address);
12973
+ }
12974
+ function ipv6Integer(address) {
12975
+ let value = address.toLowerCase();
12976
+ const dotted = /(?:^|:)(\d{1,3}(?:\.\d{1,3}){3})$/.exec(value);
12977
+ if (dotted) {
12978
+ const octets = dotted[1].split(".").map(Number);
12979
+ if (octets.some((octet) => octet > 255)) return void 0;
12980
+ value = `${value.slice(0, dotted.index)}:${(octets[0] << 8 | octets[1]).toString(16)}:${(octets[2] << 8 | octets[3]).toString(16)}`;
12981
+ }
12982
+ const halves = value.split("::");
12983
+ if (halves.length > 2) return void 0;
12984
+ const left = halves[0] ? halves[0].split(":") : [];
12985
+ const right = halves[1] ? halves[1].split(":") : [];
12986
+ const missing = 8 - left.length - right.length;
12987
+ if (halves.length === 1 && missing !== 0 || missing < 0) return void 0;
12988
+ const groups = [...left, ...Array(missing).fill("0"), ...right];
12989
+ if (groups.length !== 8 || groups.some((group) => !/^[0-9a-f]{1,4}$/.test(group)))
12990
+ return void 0;
12991
+ return groups.reduce((result, group) => result << 16n | BigInt(`0x${group}`), 0n);
12992
+ }
12993
+ function inIpv6Range(value, base, prefix) {
12994
+ const baseValue = ipv6Integer(base);
12995
+ if (baseValue === void 0) return false;
12996
+ const shift = BigInt(128 - prefix);
12997
+ return value >> shift === baseValue >> shift;
12998
+ }
12999
+ function isPublicAddress(address) {
13000
+ const mapped = mappedIpv4(address);
13001
+ if (mapped) return isPublicAddress(mapped);
13002
+ if (isIP(address) === 4) {
13003
+ const [a, b] = address.split(".").map(Number);
13004
+ return !(a === 0 || a === 10 || a === 127 || a >= 224 || a === 100 && b >= 64 && b <= 127 || a === 169 && b === 254 || a === 172 && b >= 16 && b <= 31 || a === 192 && b === 0 || a === 192 && b === 2 || a === 192 && b === 88 || a === 192 && b === 168 || a === 198 && (b === 18 || b === 19) || a === 198 && b === 51 || a === 203 && b === 0 && Number(address.split(".")[2]) === 113);
13005
+ }
13006
+ if (isIP(address) === 6) {
13007
+ const value = ipv6Integer(address);
13008
+ if (value === void 0 || !inIpv6Range(value, "2000::", 3)) return false;
13009
+ return ![
13010
+ ["2001::", 23],
13011
+ // IETF protocol assignments, including Teredo and benchmarking
13012
+ ["2001:db8::", 32],
13013
+ // documentation
13014
+ ["2002::", 16],
13015
+ // 6to4 (may embed a private IPv4 destination)
13016
+ ["2620:4f:8000::", 48],
13017
+ // documentation
13018
+ ["3fff::", 20]
13019
+ // documentation
13020
+ ].some(([base, prefix]) => inIpv6Range(value, base, prefix));
13021
+ }
13022
+ return false;
13023
+ }
13024
+ function validateHop(url, initial) {
13025
+ if (!["http:", "https:"].includes(url.protocol))
13026
+ throw new Error("publication URL must use HTTPS");
13027
+ if (url.username || url.password) throw new Error("publication URL must not contain credentials");
13028
+ if (url.hash) throw new Error("publication URL must not contain a fragment");
13029
+ if (url.search) throw new Error("publication URL must not contain query parameters");
13030
+ if (initial && !SNAPSHOT_PATH.test(url.pathname))
13031
+ throw new Error("expected a canonical public snapshot or download URL");
13032
+ }
13033
+ async function pinnedAddress(url, lookup) {
13034
+ const hostname4 = url.hostname.replace(/^\[|\]$/g, "");
13035
+ const literalFamily = isIP(hostname4);
13036
+ const addresses = literalFamily ? [{ address: hostname4, family: literalFamily }] : await lookup(hostname4, { all: true, verbatim: true });
13037
+ if (!addresses.length) throw new Error(`publication host ${url.hostname} did not resolve`);
13038
+ const loopbackDevelopment = url.protocol === "http:" && (url.hostname === "localhost" || addresses.every(({ address }) => isLoopback(address)));
13039
+ if (url.protocol === "http:" && !loopbackDevelopment)
13040
+ throw new Error(
13041
+ "publication URL must use HTTPS (HTTP is allowed only for loopback development)"
13042
+ );
13043
+ if (!loopbackDevelopment && addresses.some(({ address }) => !isPublicAddress(address)))
13044
+ throw new Error("publication URL resolves to a private or reserved address");
13045
+ return addresses[0];
13046
+ }
13047
+ async function readHop(url, options) {
13048
+ const pinned = await pinnedAddress(url, options.lookup);
13049
+ return new Promise((resolve2, reject) => {
13050
+ const request = (url.protocol === "https:" ? httpsRequest : httpRequest)(
13051
+ url,
13052
+ {
13053
+ method: "GET",
13054
+ headers: { accept: "application/json" },
13055
+ lookup: (_hostname, _opts, callback) => callback(null, pinned.address, pinned.family)
13056
+ },
13057
+ (response) => {
13058
+ const status = response.statusCode ?? 0;
13059
+ if (status >= 300 && status < 400) {
13060
+ response.resume();
13061
+ const location = response.headers.location;
13062
+ if (!location) return reject(new Error("publication redirect has no destination"));
13063
+ try {
13064
+ return resolve2({ redirect: new URL(location, url) });
13065
+ } catch {
13066
+ return reject(new Error("publication redirect has an invalid destination"));
13067
+ }
13068
+ }
13069
+ if (status !== 200) {
13070
+ response.resume();
13071
+ return reject(new Error(`publication download returned HTTP ${status}`));
13072
+ }
13073
+ const contentType = String(response.headers["content-type"] ?? "").split(";", 1)[0].trim().toLowerCase();
13074
+ if (contentType !== "application/json") {
13075
+ response.resume();
13076
+ return reject(new Error("publication download must be application/json"));
13077
+ }
13078
+ const encoding = String(response.headers["content-encoding"] ?? "identity").toLowerCase();
13079
+ if (encoding !== "identity") {
13080
+ response.resume();
13081
+ return reject(new Error("publication download uses unsupported content encoding"));
13082
+ }
13083
+ let size = 0;
13084
+ const chunks = [];
13085
+ response.on("data", (chunk) => {
13086
+ size += chunk.length;
13087
+ if (size > options.maxBytes)
13088
+ response.destroy(new Error("publication download is too large"));
13089
+ else chunks.push(chunk);
13090
+ });
13091
+ response.on("end", () => {
13092
+ try {
13093
+ resolve2({
13094
+ body: new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode(
13095
+ Buffer.concat(chunks)
13096
+ )
13097
+ });
13098
+ } catch {
13099
+ reject(new Error("publication download is not valid UTF-8"));
13100
+ }
13101
+ });
13102
+ }
13103
+ );
13104
+ const deadline = setTimeout(
13105
+ () => request.destroy(new Error("publication download timed out")),
13106
+ options.timeoutMs
13107
+ );
13108
+ request.on("close", () => clearTimeout(deadline));
13109
+ request.on("error", reject);
13110
+ request.end();
13111
+ });
13112
+ }
13113
+ async function fetchPublicWorkflowPackage(value, options = {}) {
13114
+ let url;
13115
+ try {
13116
+ url = new URL(value);
13117
+ } catch {
13118
+ throw new Error("expected a canonical public snapshot or download URL");
13119
+ }
13120
+ validateHop(url, true);
13121
+ if (!url.pathname.endsWith("/download")) url.pathname += "/download";
13122
+ const sourceUrl = url.toString();
13123
+ for (let redirects = 0; ; redirects += 1) {
13124
+ validateHop(url, false);
13125
+ const result = await readHop(url, {
13126
+ lookup: options.lookup ?? dnsLookup,
13127
+ timeoutMs: options.timeoutMs ?? DEFAULT_TIMEOUT_MS,
13128
+ maxBytes: options.maxBytes ?? PUBLIC_WORKFLOW_MAX_BYTES
13129
+ });
13130
+ if (result.body !== void 0) return { raw: result.body, sourceUrl };
13131
+ if (!result.redirect || redirects >= MAX_REDIRECTS)
13132
+ throw new Error("publication download exceeded three redirects");
13133
+ if (url.protocol === "https:" && result.redirect.protocol !== "https:")
13134
+ throw new Error("publication redirect must not downgrade HTTPS");
13135
+ url = result.redirect;
13136
+ }
13137
+ }
13138
+
12844
13139
  // src/commands/workflows.ts
13140
+ function savePublicationProof(path2, apiBase, proof) {
13141
+ const saved = {
13142
+ format: "tines.workflow-publication-proof",
13143
+ version: 1,
13144
+ api_base: normalizeUrl(apiBase),
13145
+ proof
13146
+ };
13147
+ writeJsonFile(path2, saved, { secret: true });
13148
+ return saved;
13149
+ }
13150
+ function readPublicationProof(path2) {
13151
+ const saved = readStrictObject(path2, "publication proof");
13152
+ if (saved.format !== "tines.workflow-publication-proof" || saved.version !== 1 || typeof saved.api_base !== "string" || !saved.proof || typeof saved.proof.candidate_id !== "string" || typeof saved.proof.review_digest !== "string")
13153
+ die("invalid workflow publication proof file");
13154
+ return saved;
13155
+ }
13156
+ async function confirmPublicationAction(question) {
13157
+ const rl = createInterface5({ input: process.stdin, output: process.stderr });
13158
+ try {
13159
+ return /^(?:y|yes)$/i.test((await rl.question(`${question} [y/N] `)).trim());
13160
+ } finally {
13161
+ rl.close();
13162
+ }
13163
+ }
12845
13164
  function readJsonBody(inline, file) {
12846
13165
  if (inline !== void 0 && file !== void 0) {
12847
13166
  die("pass the JSON inline or with --file, not both");
@@ -13152,7 +13471,150 @@ async function resolveExportState(api, ref) {
13152
13471
  if (named.length > 1) die(`state name "${ref}" is ambiguous; use an id`);
13153
13472
  die(`no state named "${ref}"; use a state id or <workflow>/<state>`);
13154
13473
  }
13474
+ function publicationSource(value, apiBase) {
13475
+ if (!/^https?:\/\//i.test(value)) return { kind: "file" };
13476
+ try {
13477
+ return {
13478
+ kind: "hosted",
13479
+ snapshotId: parsePublicSnapshotReference(value, apiBase)
13480
+ };
13481
+ } catch (error) {
13482
+ if (error instanceof Error && error.message === "external_source_requires_download")
13483
+ return { kind: "remote" };
13484
+ throw error;
13485
+ }
13486
+ }
13155
13487
  function registerPackageCommands(workflows) {
13488
+ withCommon(
13489
+ workflows.command("publication-validate <file>").description("Validate a workflow package for text-only public hosting")
13490
+ ).action(async (path2, opts) => {
13491
+ const result = await client(opts).validatePublication({
13492
+ document_json: readPackageSource(path2)
13493
+ });
13494
+ if (opts.json) printJson(result);
13495
+ else {
13496
+ console.log(
13497
+ result.valid ? "valid public workflow snapshot" : "invalid public workflow snapshot"
13498
+ );
13499
+ for (const diagnostic of result.diagnostics)
13500
+ console.log(` ${diagnostic.path || "/"}: ${diagnostic.message}`);
13501
+ }
13502
+ if (!result.valid) process.exitCode = 1;
13503
+ });
13504
+ withCommon(
13505
+ workflows.command("publish [workflow]").description("Prepare or commit one exact immutable public workflow snapshot").option(
13506
+ "--from <file>",
13507
+ "prepare from an existing workflow package instead of an owned workflow"
13508
+ ).option("--proof-out <file>", "save the complete prepared proof with mode 0600").option("--proof <file>", "commit a previously saved exact proof").option("--display-name <name>", "public attribution name (never an email)").option("--license <license>", "reuse license (currently MIT)", "MIT").option("--license-year <year>", "MIT copyright year", String((/* @__PURE__ */ new Date()).getFullYear())).option("--confirm <review-digest>", "exact digest of the reviewed proof").option("--sharing-rights", "confirm rights to every bundled declaration and file").option("--recover", "reconcile this same candidate after a lost response").option("--repo <local-id>", "confirm one bundled repository ID (repeatable)", collect2, [])
13509
+ ).action(
13510
+ async (workflow2, opts) => {
13511
+ const apiBase = normalizeUrl(resolveUrl(opts));
13512
+ const api = client(opts);
13513
+ if (opts.proof) {
13514
+ if (workflow2 || opts.from || opts.proofOut || opts.displayName)
13515
+ die(
13516
+ "--proof commit mode cannot be combined with a workflow, --from, --proof-out, or --display-name"
13517
+ );
13518
+ const saved = readPublicationProof(opts.proof);
13519
+ if (normalizeUrl(saved.api_base) !== apiBase)
13520
+ die(`proof belongs to ${saved.api_base}, not ${apiBase}`);
13521
+ const proof2 = saved.proof;
13522
+ if (opts.confirm && opts.confirm !== proof2.review_digest)
13523
+ die(`confirmation digest does not match reviewed proof ${proof2.review_digest}`);
13524
+ if (!process.stdin.isTTY && (opts.confirm !== proof2.review_digest || !opts.sharingRights))
13525
+ die(`non-interactive publish requires --confirm ${proof2.review_digest} --sharing-rights`);
13526
+ if (process.stdin.isTTY && !opts.confirm) {
13527
+ process.stderr.write(`${JSON.stringify(proof2, null, 2)}
13528
+ `);
13529
+ if (!await confirmPublicationAction("Publish these exact immutable bytes?"))
13530
+ die("publication declined");
13531
+ if (!await confirmPublicationAction("Do you have sharing rights for every bundled item?"))
13532
+ die("publication declined");
13533
+ }
13534
+ let result;
13535
+ if (opts.recover) {
13536
+ try {
13537
+ result = await api.getPublicationResult(proof2.candidate_id);
13538
+ } catch (error) {
13539
+ if (!(error instanceof ApiError) || error.status !== 404) throw error;
13540
+ }
13541
+ }
13542
+ result ??= await api.publishPublication(proof2.candidate_id, {
13543
+ review_digest: proof2.review_digest,
13544
+ sharing_rights: true,
13545
+ exact_content: true,
13546
+ reviewed_repo_ids: opts.repo
13547
+ });
13548
+ if (opts.json) printJson(result);
13549
+ else console.log(`published ${result.receipt.public_url}`);
13550
+ return;
13551
+ }
13552
+ if (opts.recover) die("--recover requires --proof");
13553
+ if (!!workflow2 === !!opts.from) die("provide exactly one owned workflow or --from <file>");
13554
+ if (!opts.proofOut) die("preparing a publication requires --proof-out <file>");
13555
+ if (!opts.displayName) die("preparing a publication requires --display-name <name>");
13556
+ if (opts.license !== "MIT") die("--license currently supports only MIT");
13557
+ const year = Number(opts.licenseYear);
13558
+ if (!Number.isSafeInteger(year)) die("--license-year must be an integer");
13559
+ const source = opts.from ? { kind: "file", document_json: readPackageSource(opts.from) } : {
13560
+ kind: "owned_workflow",
13561
+ workflow_id: (await resolveWorkflow(api, workflow2)).id,
13562
+ options: { schedule_ids: [], tiers: [] }
13563
+ };
13564
+ const proof = await api.preparePublication({
13565
+ prepare_request_id: crypto.randomUUID(),
13566
+ source,
13567
+ metadata: { display_name: opts.displayName, license: "MIT", license_year: year }
13568
+ });
13569
+ savePublicationProof(opts.proofOut, apiBase, proof);
13570
+ if (opts.json) printJson(proof);
13571
+ else {
13572
+ console.log(JSON.stringify(proof, null, 2));
13573
+ console.error(`saved exact publication proof to ${opts.proofOut}`);
13574
+ }
13575
+ }
13576
+ );
13577
+ withCommon(
13578
+ workflows.command("publications").description("List your public workflow snapshots").option("--workflow <id>", "filter by owned source workflow ID")
13579
+ ).action(async (opts) => {
13580
+ const result = await client(opts).listPublications(opts.workflow);
13581
+ if (opts.json) printJson(result);
13582
+ else if (!result.items.length) console.log("no public snapshots");
13583
+ else
13584
+ table([
13585
+ ["NAME", "STATUS", "PUBLISHED", "SNAPSHOT"],
13586
+ ...result.items.map((item) => [
13587
+ item.metadata.display_name,
13588
+ item.owner_state === "published" && item.host_state === "active" ? "hosted" : "unavailable",
13589
+ new Date(item.published_at).toISOString(),
13590
+ item.snapshot_id
13591
+ ])
13592
+ ]);
13593
+ });
13594
+ withCommon(
13595
+ workflows.command("unpublish <public-url-or-id>").description("Withdraw a hosted public snapshot").option("-y, --yes", "skip the explicit withdrawal prompt")
13596
+ ).action(async (reference, opts) => {
13597
+ if (!opts.yes && !process.stdin.isTTY) die("non-interactive withdrawal requires --yes");
13598
+ if (!opts.yes && !await confirmPublicationAction("Withdraw this hosted snapshot now?"))
13599
+ die("withdrawal declined");
13600
+ const apiBase = normalizeUrl(resolveUrl(opts));
13601
+ const snapshotId = /^https?:/i.test(reference) ? parsePublicSnapshotReference(reference, apiBase) : reference;
13602
+ const result = await client(opts).withdrawPublication(snapshotId);
13603
+ if (opts.json) printJson(result);
13604
+ else console.log(`withdrew ${result.receipt.public_url}`);
13605
+ });
13606
+ withCommon(
13607
+ workflows.command("restore-publication <public-url-or-id>").description("Restore an owner-withdrawn public snapshot when host policy permits").option("-y, --yes", "skip the explicit restoration prompt")
13608
+ ).action(async (reference, opts) => {
13609
+ if (!opts.yes && !process.stdin.isTTY) die("non-interactive restoration requires --yes");
13610
+ if (!opts.yes && !await confirmPublicationAction("Restore this hosted snapshot now?"))
13611
+ die("restoration declined");
13612
+ const apiBase = normalizeUrl(resolveUrl(opts));
13613
+ const snapshotId = /^https?:/i.test(reference) ? parsePublicSnapshotReference(reference, apiBase) : reference;
13614
+ const result = await client(opts).restorePublication(snapshotId);
13615
+ if (opts.json) printJson(result);
13616
+ else console.log(`restored ${result.receipt.public_url}`);
13617
+ });
13156
13618
  withCommon(
13157
13619
  workflows.command("export <workflow-id-or-unambiguous-name>").description("Export a canonical workflow package JSON document").option("--project <id-or-name>", "source project for selected project-bound configuration").option("--schedule <id>", "include this source schedule ID (repeatable)", collect2, []).option(
13158
13620
  "--tier <state-ref=tier>",
@@ -13193,25 +13655,57 @@ function registerPackageCommands(workflows) {
13193
13655
  }
13194
13656
  );
13195
13657
  withCommon(
13196
- workflows.command("validate <file>").description("Validate a workflow package file (use - for stdin)")
13658
+ workflows.command("validate <file>").description("Validate a workflow package file (use - for stdin)").option("--public", "apply the stricter text-only public-hosting policy")
13197
13659
  ).action(async (path2, opts) => {
13198
- const result = await client(opts).validateLibrary({ document_json: readPackageSource(path2) });
13660
+ const api = client(opts);
13661
+ if (opts.public) {
13662
+ const result2 = await api.validatePublication({ document_json: readPackageSource(path2) });
13663
+ if (opts.json) printJson(result2);
13664
+ else {
13665
+ console.log(
13666
+ result2.valid ? "valid public workflow snapshot" : "invalid public workflow snapshot"
13667
+ );
13668
+ for (const diagnostic of result2.diagnostics)
13669
+ console.log(` ${diagnostic.path || "/"}: ${diagnostic.message}`);
13670
+ }
13671
+ if (!result2.valid) process.exitCode = 1;
13672
+ return;
13673
+ }
13674
+ const result = await api.validateLibrary({ document_json: readPackageSource(path2) });
13199
13675
  if (opts.json) printJson(result);
13200
13676
  else console.log(formatValidation(result));
13201
13677
  if (!result.valid) process.exitCode = 1;
13202
13678
  });
13203
13679
  withCommon(
13204
- workflows.command("preview <file>").description("Prepare and fully review a destination workflow package plan").option("--choices <file>", "destination choices JSON file").option("--plan-out <file>", "atomically save the signed plan for a later install")
13680
+ workflows.command("preview <file-or-public-url>").description("Prepare and fully review a destination workflow package plan").option("--choices <file>", "destination choices JSON file").option("--plan-out <file>", "atomically save the signed plan for a later install")
13205
13681
  ).action(async (path2, opts) => {
13206
- const raw = readPackageSource(path2);
13207
13682
  const choices = opts.choices ? readStrictObject(opts.choices, "choices file") : void 0;
13208
- const plan = await client(opts).prepareWorkflowPackage({ document_json: raw, choices });
13209
- if (opts.planOut) saveWorkflowPackagePlan(opts.planOut, resolveUrl(opts), plan);
13683
+ const apiBase = normalizeUrl(resolveUrl(opts));
13684
+ const api = client(opts);
13685
+ const source = publicationSource(path2, apiBase);
13686
+ let remoteSource;
13687
+ let plan;
13688
+ if (source.kind === "hosted") {
13689
+ plan = await api.prepareHostedWorkflowPackage(source.snapshotId, choices);
13690
+ } else {
13691
+ if (source.kind === "remote") {
13692
+ const fetched = await fetchPublicWorkflowPackage(path2);
13693
+ remoteSource = {
13694
+ url: fetched.sourceUrl,
13695
+ bytes_sha256: workflowPackageBytesSha256(fetched.raw)
13696
+ };
13697
+ plan = await api.prepareWorkflowPackage({ document_json: fetched.raw, choices });
13698
+ } else {
13699
+ const raw = readPackageSource(path2);
13700
+ plan = await api.prepareWorkflowPackage({ document_json: raw, choices });
13701
+ }
13702
+ }
13703
+ if (opts.planOut) saveWorkflowPackagePlan(opts.planOut, apiBase, plan, remoteSource);
13210
13704
  if (opts.json) printJson(plan);
13211
13705
  else console.log(formatWorkflowPackageReview(plan));
13212
13706
  });
13213
13707
  withCommon(
13214
- workflows.command("install <file>").description("Install one exactly reviewed workflow package plan").option("--choices <file>", "destination choices JSON file (interactive preparation only)").option("--plan <file>", "signed plan saved by workflows preview").option("--confirm <plan-digest>", "exact reviewed plan digest (required outside a TTY)")
13708
+ workflows.command("install <file-or-public-url>").description("Install one exactly reviewed workflow package plan").option("--choices <file>", "destination choices JSON file (interactive preparation only)").option("--plan <file>", "signed plan saved by workflows preview").option("--confirm <plan-digest>", "exact reviewed plan digest (required outside a TTY)")
13215
13709
  ).action(
13216
13710
  async (path2, opts) => {
13217
13711
  if (path2 === "-" && process.stdin.isTTY)
@@ -13226,15 +13720,33 @@ function registerPackageCommands(workflows) {
13226
13720
  die(
13227
13721
  "stdin package input cannot also provide interactive confirmation; use preview --plan-out first"
13228
13722
  );
13229
- const { raw, document } = await localDocument(path2);
13230
13723
  const apiBase = normalizeUrl(resolveUrl(opts));
13231
13724
  const api = client(opts);
13725
+ const source = publicationSource(path2, apiBase);
13726
+ let raw;
13232
13727
  let plan;
13233
13728
  let planPath = opts.plan;
13234
13729
  if (planPath) {
13235
13730
  const saved = readWorkflowPackagePlan(planPath);
13236
13731
  if (normalizeUrl(saved.api_base) !== apiBase)
13237
13732
  die(`plan belongs to ${saved.api_base}, not ${apiBase}`);
13733
+ if (saved.remote_source) {
13734
+ if (source.kind !== "remote")
13735
+ die("saved remote plan must be installed from its public source URL");
13736
+ const fetched = await fetchPublicWorkflowPackage(path2);
13737
+ if (fetched.sourceUrl !== saved.remote_source.url || workflowPackageBytesSha256(fetched.raw) !== saved.remote_source.bytes_sha256)
13738
+ die("public source changed since preview; create and review a fresh plan");
13739
+ raw = fetched.raw;
13740
+ } else if (saved.plan.source?.kind === "hosted_publication") {
13741
+ if (source.kind !== "hosted" || source.snapshotId !== saved.plan.source.snapshot_id)
13742
+ die("saved hosted plan belongs to a different public snapshot");
13743
+ await api.getPublicSnapshotStatus(source.snapshotId);
13744
+ raw = canonicalWorkflowPackage(saved.plan.document);
13745
+ } else {
13746
+ if (source.kind !== "file") die("saved file plan must be installed from its file");
13747
+ raw = readPackageSource(path2);
13748
+ }
13749
+ const { document } = await packageDocument(raw);
13238
13750
  if (saved.document_digest !== document.digest || saved.plan.document_digest !== document.digest || canonicalWorkflowPackage(saved.plan.document) !== canonicalWorkflowPackage(document))
13239
13751
  die(
13240
13752
  `package digest ${document.digest} does not match saved plan ${saved.document_digest}`
@@ -13242,9 +13754,25 @@ function registerPackageCommands(workflows) {
13242
13754
  plan = saved.plan;
13243
13755
  } else {
13244
13756
  const choices = opts.choices ? readStrictObject(opts.choices, "choices file") : void 0;
13245
- plan = await api.prepareWorkflowPackage({ document_json: raw, choices });
13246
- planPath = `${path2}.plan.json`;
13247
- saveWorkflowPackagePlan(planPath, apiBase, plan);
13757
+ let remoteSource;
13758
+ if (source.kind === "hosted") {
13759
+ plan = await api.prepareHostedWorkflowPackage(source.snapshotId, choices);
13760
+ raw = canonicalWorkflowPackage(plan.document);
13761
+ } else if (source.kind === "remote") {
13762
+ const fetched = await fetchPublicWorkflowPackage(path2);
13763
+ raw = fetched.raw;
13764
+ await packageDocument(raw);
13765
+ remoteSource = {
13766
+ url: fetched.sourceUrl,
13767
+ bytes_sha256: workflowPackageBytesSha256(raw)
13768
+ };
13769
+ plan = await api.prepareWorkflowPackage({ document_json: raw, choices });
13770
+ } else {
13771
+ ({ raw } = await localDocument(path2));
13772
+ plan = await api.prepareWorkflowPackage({ document_json: raw, choices });
13773
+ }
13774
+ planPath = source.kind === "file" ? `${path2}.plan.json` : "workflow-publication.plan.json";
13775
+ saveWorkflowPackagePlan(planPath, apiBase, plan, remoteSource);
13248
13776
  console.error(`saved retryable signed plan to ${planPath}`);
13249
13777
  }
13250
13778
  if (opts.confirm !== void 0 && opts.confirm !== plan.plan_digest)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tines",
3
- "version": "0.0.236",
3
+ "version": "0.0.238",
4
4
  "description": "CLI for Tines, an orchestration layer for AI agents",
5
5
  "repository": {
6
6
  "type": "git",