@minnovate/shadow-publisher 0.5.0 → 0.6.1

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.
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "git-publisher",
3
3
  "displayName": "Git Publisher",
4
- "version": "0.5.0",
4
+ "version": "0.6.1",
5
5
  "description": "Publish an app folder's Git history to the serverless Git publishing backend and keep it updated (\"publish my app\", \"update my app\", \"fetch the engineered version\").",
6
6
  "author": { "name": "git-publisher project" },
7
7
  "keywords": ["git", "publish", "deploy", "serverless"]
@@ -76,7 +76,7 @@ exclusion rules are a safety net, not a secret scanner). Then publish it with st
76
76
  ## 3. Every update — and the first publish after `init`
77
77
 
78
78
  ```bash
79
- publisher sync . --message "<a few words on what changed>" --json
79
+ publisher sync . --message "<a few words on what changed>" --timeout 90 --json
80
80
  ```
81
81
 
82
82
  That one command records what changed in the folder, brings in anything published meanwhile (a
@@ -85,12 +85,44 @@ the message in the user's words ("Add a contact page"), not git's.
85
85
 
86
86
  ## 4. Reading the result
87
87
 
88
+ After a publish, the command follows the app going live, asking every 5 seconds, for at most
89
+ `--timeout` seconds. Keep that at 90: your shell tool shows output only when a command ends, and
90
+ cuts one off after two minutes. With `--json` it prints one line per step, then the result object
91
+ as the LAST line.
92
+
93
+ If the run is still going when the 90 seconds are up, `progress.kind` is `"timed-out"` and
94
+ `progress.next.command` picks it up. Run it (it is `publisher follow --publication <id> --json`;
95
+ add `--timeout 90`) and repeat until `progress.kind` is no longer `"timed-out"`, up to about 15
96
+ minutes in all. Each call prints the steps as they now stand, from the first: relay only the ones
97
+ you have not relayed yet.
98
+
99
+ ```json
100
+ {"event":"stage","stage":"running","word":"checked","state":"in-progress","at":"2026-09-23T10:00:10Z"}
101
+ ```
102
+
103
+ Relay each step in its `word` — **received**, **checked**, **going live**, **live** — and nothing
104
+ else: not the `stage` name, not a step you guessed. Read the result from the last line. **If the
105
+ command was cut off while following**, the publication had already succeeded before the following
106
+ began: say it is published, and follow it with `publisher follow --publication <publicationId>
107
+ --timeout 90 --json` (the id is in the `sync` output's `publication`, or `publisher status`). Do
108
+ not run `sync` again to find out. `--no-wait` skips the following, for when the user asks not to wait.
109
+
88
110
  `state` says what happened; `hint` is one sentence you may pass on as it is.
89
111
 
90
112
  - `"published"` → the app was published. Mention what was recorded (`recorded.committed`) and, if
91
- `recorded.excluded` is not empty, what was left out and why. Then tell them the portal shows the
92
- app moving through **received → checked → going live → live**, and that it appears there within
93
- a few minutes. (The publisher cannot follow those steps from here yet.)
113
+ `recorded.excluded` is not empty, what was left out and why. Then read `progress` (null with
114
+ `--no-wait`) and pass on its `hint`:
115
+ - `kind: "settled"` with `ending` `"live"`: the app is live. `"not-deployed"`: published and
116
+ checked, and this organisation does not deploy automatically. `"failed"`: it stopped at
117
+ `failedAt`; the portal says why — do not guess the reason. `"stopped"`: a newer publication
118
+ replaced it.
119
+ - `"timed-out"`: still going; run `progress.next.command` as above. After about 15 minutes in
120
+ all, stop and point them to the portal (`progress.portal`).
121
+ - `"unauthorized"`: published, but the portal did not accept this sign-in; run `publisher login`
122
+ before the next publish.
123
+ - `"unavailable"`, `"forbidden"` or `"malformed"`: the publish itself succeeded, but this
124
+ terminal cannot show the steps. Tell them to follow it in the portal. Never report a step that
125
+ no `stage` line said.
94
126
  - `"up_to_date"` → nothing new to publish; the service already has this version.
95
127
  - `"conflict"` → the user's change and a published change touched the same lines of
96
128
  `conflicts[]`. **Nothing was published and nothing was lost.** Do not resolve it on your own and
@@ -15,7 +15,9 @@ result. Never run `git add`, `git commit`, `git push`, `git pull`, `git merge`,
15
15
  publisher sync . --json
16
16
  ```
17
17
 
18
- Read `state` exactly as the `publish-app` skill describes (step 4). `merged` says whether anything
18
+ Read the step lines and `state` exactly as the `publish-app` skill describes (step 4): after a
19
+ publish it follows the app through received → checked → going live → live before it prints the
20
+ result. `merged` says whether anything
19
21
  published elsewhere was brought in (`"fast-forward"` or `"merge"`); mention it when it is not null.
20
22
 
21
23
  ## Just looking
@@ -3585,12 +3585,12 @@ async function resolveRevs(dir, revs, opts = {}) {
3585
3585
  const out = revs.map(
3586
3586
  (r) => r.startsWith("-") ? null : r.includes("\0") ? new PublisherError("INVALID_REQUEST", "NUL in git argument") : null
3587
3587
  );
3588
- const ask2 = revs.flatMap((r, i) => out[i] === null && !r.startsWith("-") ? [i] : []);
3589
- if (ask2.length === 0) return out;
3590
- const { stdout } = await runGit(["cat-file", "--batch-check=%(objectname)", "-Z"], { cwd: dir, input: ask2.map((i) => revs[i]).join("\0") + "\0", ...opts });
3588
+ const ask3 = revs.flatMap((r, i) => out[i] === null && !r.startsWith("-") ? [i] : []);
3589
+ if (ask3.length === 0) return out;
3590
+ const { stdout } = await runGit(["cat-file", "--batch-check=%(objectname)", "-Z"], { cwd: dir, input: ask3.map((i) => revs[i]).join("\0") + "\0", ...opts });
3591
3591
  const records = stdout.toString("utf8").split("\0");
3592
3592
  const misses = [];
3593
- for (const [j, i] of ask2.entries()) {
3593
+ for (const [j, i] of ask3.entries()) {
3594
3594
  const record2 = records[j] ?? "";
3595
3595
  if (record2 && !/ (missing|ambiguous)$/.test(record2)) out[i] = record2;
3596
3596
  else if (!revs[i].includes("^{")) misses.push(i);
@@ -4493,6 +4493,236 @@ async function updateEntry(url2, change) {
4493
4493
  await writeCredentials(file);
4494
4494
  }
4495
4495
 
4496
+ // ../client/src/transport.ts
4497
+ function assertCredentialEndpoint(endpoint) {
4498
+ let url2;
4499
+ try {
4500
+ url2 = new URL(endpoint);
4501
+ } catch {
4502
+ throw new PublisherError("INVALID_REQUEST", `endpoint is not a URL: ${endpoint}`);
4503
+ }
4504
+ const loopback = url2.hostname === "127.0.0.1" || url2.hostname === "localhost" || url2.hostname === "[::1]";
4505
+ if (url2.protocol !== "https:" && !(url2.protocol === "http:" && loopback)) {
4506
+ throw new PublisherError("INVALID_REQUEST", `endpoint must be https (plain http only on loopback): ${url2.origin}`);
4507
+ }
4508
+ }
4509
+ var Transport = class {
4510
+ constructor(opts) {
4511
+ this.opts = opts;
4512
+ this.fetchImpl = opts.fetch ?? fetch;
4513
+ this.endpoint = opts.endpoint.replace(/\/+$/, "");
4514
+ }
4515
+ opts;
4516
+ fetchImpl;
4517
+ endpoint;
4518
+ async request(method, path, body, ro = {}) {
4519
+ const retries = ro.retries ?? this.opts.maxRetries ?? 4;
4520
+ let attempt = 0;
4521
+ let refreshed = false;
4522
+ for (; ; ) {
4523
+ attempt++;
4524
+ try {
4525
+ return await this.once(method, path, body, ro);
4526
+ } catch (e) {
4527
+ if (e instanceof PublisherError && e.httpStatus === 401 && !refreshed && this.opts.refresh) {
4528
+ refreshed = true;
4529
+ if (await this.opts.refresh()) continue;
4530
+ }
4531
+ if (!(e instanceof PublisherError) || !e.retryable || attempt > retries || ro.signal?.aborted) throw e;
4532
+ const delay2 = (this.opts.retryDelayMs ?? 300) * 2 ** (attempt - 1) * (0.5 + Math.random());
4533
+ await sleep(Math.min(delay2, 1e4), ro.signal);
4534
+ }
4535
+ }
4536
+ }
4537
+ async once(method, path, body, ro) {
4538
+ assertCredentialEndpoint(this.endpoint);
4539
+ const token = await this.opts.token();
4540
+ const controller = new AbortController();
4541
+ const timer = setTimeout(() => controller.abort(new PublisherError("NETWORK", "request timed out", { retryable: true })), this.opts.timeoutMs ?? 6e4);
4542
+ const onAbort = () => controller.abort(new PublisherError("CANCELLED", "cancelled"));
4543
+ ro.signal?.addEventListener("abort", onAbort, { once: true });
4544
+ if (ro.signal?.aborted) onAbort();
4545
+ try {
4546
+ let res;
4547
+ try {
4548
+ res = await this.fetchImpl(`${this.endpoint}${path}`, {
4549
+ method,
4550
+ headers: {
4551
+ authorization: `Bearer ${token}`,
4552
+ [PROTOCOL_HEADER]: String(PROTOCOL_VERSION),
4553
+ [CLIENT_HEADER]: `${this.opts.clientName}/${this.opts.clientVersion}`,
4554
+ ...body !== void 0 ? { "content-type": "application/json" } : {},
4555
+ ...ro.headers ?? {}
4556
+ },
4557
+ ...body !== void 0 ? { body: JSON.stringify(body) } : {},
4558
+ signal: controller.signal
4559
+ });
4560
+ } catch (e) {
4561
+ if (controller.signal.aborted && controller.signal.reason instanceof PublisherError) throw controller.signal.reason;
4562
+ throw new PublisherError("NETWORK", `request failed: ${e.message}`, { retryable: true, cause: e });
4563
+ }
4564
+ const text = await res.text();
4565
+ let parsed = null;
4566
+ if (text) {
4567
+ try {
4568
+ parsed = JSON.parse(text);
4569
+ } catch {
4570
+ parsed = null;
4571
+ }
4572
+ }
4573
+ if (res.ok) return { status: res.status, body: parsed, headers: res.headers };
4574
+ const err = parsed?.error;
4575
+ const code = err && ERROR_CODES.includes(err.code) ? err.code : res.status >= 500 ? "UNAVAILABLE" : "INVALID_REQUEST";
4576
+ throw new PublisherError(code, err?.message ?? `HTTP ${res.status}`, {
4577
+ retryable: err?.retryable ?? (res.status === 429 || res.status >= 500),
4578
+ httpStatus: res.status,
4579
+ ...err?.details ? { details: err.details } : {}
4580
+ });
4581
+ } finally {
4582
+ clearTimeout(timer);
4583
+ ro.signal?.removeEventListener("abort", onAbort);
4584
+ }
4585
+ }
4586
+ };
4587
+ function sleep(ms, signal) {
4588
+ return new Promise((resolve3, reject) => {
4589
+ const t = setTimeout(resolve3, ms);
4590
+ signal?.addEventListener(
4591
+ "abort",
4592
+ () => {
4593
+ clearTimeout(t);
4594
+ reject(new PublisherError("CANCELLED", "cancelled"));
4595
+ },
4596
+ { once: true }
4597
+ );
4598
+ });
4599
+ }
4600
+
4601
+ // ../client/src/run-progress.ts
4602
+ var RUN_STAGES = ["received", "running", "deploying", "live"];
4603
+ var STAGE_WORDS = { received: "received", running: "checked", deploying: "going live", live: "live" };
4604
+ var STATES = ["pending", "in-progress", "done", "failed", "skipped"];
4605
+ function readStages(run) {
4606
+ if (!isRecord(run)) return { malformed: "a run that is not an object" };
4607
+ if (!Object.hasOwn(run, "stages")) return null;
4608
+ const stages = run["stages"];
4609
+ if (!Array.isArray(stages) || stages.length !== RUN_STAGES.length) return { malformed: "stages that are not exactly four" };
4610
+ const out = [];
4611
+ for (const [index, entry] of stages.entries()) {
4612
+ const stage = RUN_STAGES[index];
4613
+ if (!isRecord(entry) || entry["stage"] !== stage) return { malformed: `stage ${index + 1} is not ${stage}` };
4614
+ const state = entry["state"];
4615
+ if (!STATES.includes(state)) return { malformed: `stage ${stage} has an unknown state` };
4616
+ const at = entry["at"];
4617
+ if (typeof at !== "string" && at !== null) return { malformed: `stage ${stage} has an unreadable time` };
4618
+ out.push({ stage, word: STAGE_WORDS[stage], state, at });
4619
+ }
4620
+ return { stages: out };
4621
+ }
4622
+ function ending(stages) {
4623
+ if (stages.some((s) => s.state === "pending" || s.state === "in-progress")) return null;
4624
+ if (stages.some((s) => s.state === "failed")) return "failed";
4625
+ if (stages[3].state === "done") return "live";
4626
+ if (stages[1].state === "done" && stages[2].state === "skipped") return "not-deployed";
4627
+ return "stopped";
4628
+ }
4629
+ var POLL_INTERVAL_MS = 5e3;
4630
+ var FOLLOW_TIMEOUT_MS = 15 * 6e4;
4631
+ async function followRun(input, opts) {
4632
+ const fetchImpl = opts.fetch ?? fetch;
4633
+ const sleep2 = opts.sleep ?? sleep;
4634
+ const now = opts.now ?? Date.now;
4635
+ const interval = opts.intervalMs ?? POLL_INTERVAL_MS;
4636
+ const deadline = now() + (opts.timeoutMs ?? FOLLOW_TIMEOUT_MS);
4637
+ const query = new URLSearchParams({ publication: input.publicationId, ref: input.ref, commit: input.commit });
4638
+ const url2 = `${input.portal.replace(/\/+$/, "")}/api/v1/terminal/repositories/${encodeURIComponent(input.repoId)}/runs?${query}`;
4639
+ const seen = /* @__PURE__ */ new Map();
4640
+ let last = null;
4641
+ let lastError = null;
4642
+ let refreshed = false;
4643
+ for (; ; ) {
4644
+ const answer = await ask(fetchImpl, url2, await opts.token());
4645
+ if (answer.status === 401 && !refreshed && opts.refresh !== void 0) {
4646
+ refreshed = true;
4647
+ if (await opts.refresh()) continue;
4648
+ }
4649
+ if (answer.status === 401) return { kind: "unauthorized" };
4650
+ if (answer.status === 404) return { kind: "unavailable", reason: "no-route" };
4651
+ if (answer.status === 403) return { kind: "forbidden" };
4652
+ if (answer.status === 200) {
4653
+ const run = soleRun(answer.body);
4654
+ if ("detail" in run) return { kind: "malformed", detail: run.detail };
4655
+ if (run.run !== null) {
4656
+ const read = readStages(run.run);
4657
+ if (read === null) return { kind: "unavailable", reason: "no-stages" };
4658
+ if ("malformed" in read) return { kind: "malformed", detail: read.malformed };
4659
+ last = read.stages;
4660
+ for (const entry of read.stages) {
4661
+ if (entry.state === "pending" || seen.get(entry.stage) === entry.state) continue;
4662
+ seen.set(entry.stage, entry.state);
4663
+ opts.onStage?.(entry);
4664
+ }
4665
+ const end = ending(read.stages);
4666
+ if (end !== null) return { kind: "settled", ending: end, failedAt: read.stages.find((s) => s.state === "failed")?.word ?? null, stages: read.stages };
4667
+ }
4668
+ lastError = null;
4669
+ } else {
4670
+ lastError = answer.status === 0 ? "the portal could not be reached" : `the portal answered ${answer.status}`;
4671
+ }
4672
+ if (now() + interval > deadline) return { kind: "timed-out", stages: last, lastError };
4673
+ await sleep2(interval);
4674
+ }
4675
+ }
4676
+ function soleRun(body) {
4677
+ if (!isRecord(body) || !Array.isArray(body["runs"])) return { detail: "an answer with no list of runs" };
4678
+ const runs = body["runs"];
4679
+ if (runs.length > 1) return { detail: "more than the one run this publication started" };
4680
+ return { run: runs[0] ?? null };
4681
+ }
4682
+ async function ask(fetchImpl, url2, token) {
4683
+ try {
4684
+ const res = await fetchImpl(url2, { headers: { authorization: `Bearer ${token}`, accept: "application/json" }, signal: AbortSignal.timeout(3e4) });
4685
+ const text = await res.text();
4686
+ let body = null;
4687
+ try {
4688
+ body = text ? JSON.parse(text) : null;
4689
+ } catch {
4690
+ if (res.status === 200) return { status: 200, body: void 0 };
4691
+ }
4692
+ return { status: res.status, body };
4693
+ } catch {
4694
+ return { status: 0, body: null };
4695
+ }
4696
+ }
4697
+ function portalForEndpoint(endpoint) {
4698
+ let url2;
4699
+ try {
4700
+ url2 = new URL(endpoint);
4701
+ } catch {
4702
+ return null;
4703
+ }
4704
+ if (url2.protocol !== "https:" || url2.pathname.replace(/\/+$/, "") !== "/publish") return null;
4705
+ return url2.origin;
4706
+ }
4707
+ var RUN_PROGRESS = "run-progress";
4708
+ async function confirmPortal(portal, endpoint, fetchImpl = fetch) {
4709
+ try {
4710
+ const res = await fetchImpl(`${portal}/api/v1/config`, { signal: AbortSignal.timeout(1e4) });
4711
+ if (!res.ok) return null;
4712
+ const body = await res.json();
4713
+ const named = body?.publishingEndpoint;
4714
+ const bare = (u) => u.replace(/\/+$/, "");
4715
+ if (typeof named !== "string" || bare(named) !== bare(endpoint)) return null;
4716
+ const terminal = body?.terminal;
4717
+ return { terminal: Array.isArray(terminal) && terminal.every((name) => typeof name === "string") ? terminal : [] };
4718
+ } catch {
4719
+ return null;
4720
+ }
4721
+ }
4722
+ function isRecord(value) {
4723
+ return typeof value === "object" && value !== null && !Array.isArray(value);
4724
+ }
4725
+
4496
4726
  // ../client/src/onboard.ts
4497
4727
  var import_promises6 = require("node:fs/promises");
4498
4728
  var import_node_path5 = require("node:path");
@@ -4693,111 +4923,6 @@ var import_node_crypto4 = require("node:crypto");
4693
4923
  // ../client/src/publish.ts
4694
4924
  var import_promises7 = require("node:fs/promises");
4695
4925
 
4696
- // ../client/src/transport.ts
4697
- function assertCredentialEndpoint(endpoint) {
4698
- let url2;
4699
- try {
4700
- url2 = new URL(endpoint);
4701
- } catch {
4702
- throw new PublisherError("INVALID_REQUEST", `endpoint is not a URL: ${endpoint}`);
4703
- }
4704
- const loopback = url2.hostname === "127.0.0.1" || url2.hostname === "localhost" || url2.hostname === "[::1]";
4705
- if (url2.protocol !== "https:" && !(url2.protocol === "http:" && loopback)) {
4706
- throw new PublisherError("INVALID_REQUEST", `endpoint must be https (plain http only on loopback): ${url2.origin}`);
4707
- }
4708
- }
4709
- var Transport = class {
4710
- constructor(opts) {
4711
- this.opts = opts;
4712
- this.fetchImpl = opts.fetch ?? fetch;
4713
- this.endpoint = opts.endpoint.replace(/\/+$/, "");
4714
- }
4715
- opts;
4716
- fetchImpl;
4717
- endpoint;
4718
- async request(method, path, body, ro = {}) {
4719
- const retries = ro.retries ?? this.opts.maxRetries ?? 4;
4720
- let attempt = 0;
4721
- let refreshed = false;
4722
- for (; ; ) {
4723
- attempt++;
4724
- try {
4725
- return await this.once(method, path, body, ro);
4726
- } catch (e) {
4727
- if (e instanceof PublisherError && e.httpStatus === 401 && !refreshed && this.opts.refresh) {
4728
- refreshed = true;
4729
- if (await this.opts.refresh()) continue;
4730
- }
4731
- if (!(e instanceof PublisherError) || !e.retryable || attempt > retries || ro.signal?.aborted) throw e;
4732
- const delay2 = (this.opts.retryDelayMs ?? 300) * 2 ** (attempt - 1) * (0.5 + Math.random());
4733
- await sleep(Math.min(delay2, 1e4), ro.signal);
4734
- }
4735
- }
4736
- }
4737
- async once(method, path, body, ro) {
4738
- assertCredentialEndpoint(this.endpoint);
4739
- const token = await this.opts.token();
4740
- const controller = new AbortController();
4741
- const timer = setTimeout(() => controller.abort(new PublisherError("NETWORK", "request timed out", { retryable: true })), this.opts.timeoutMs ?? 6e4);
4742
- const onAbort = () => controller.abort(new PublisherError("CANCELLED", "cancelled"));
4743
- ro.signal?.addEventListener("abort", onAbort, { once: true });
4744
- if (ro.signal?.aborted) onAbort();
4745
- try {
4746
- let res;
4747
- try {
4748
- res = await this.fetchImpl(`${this.endpoint}${path}`, {
4749
- method,
4750
- headers: {
4751
- authorization: `Bearer ${token}`,
4752
- [PROTOCOL_HEADER]: String(PROTOCOL_VERSION),
4753
- [CLIENT_HEADER]: `${this.opts.clientName}/${this.opts.clientVersion}`,
4754
- ...body !== void 0 ? { "content-type": "application/json" } : {},
4755
- ...ro.headers ?? {}
4756
- },
4757
- ...body !== void 0 ? { body: JSON.stringify(body) } : {},
4758
- signal: controller.signal
4759
- });
4760
- } catch (e) {
4761
- if (controller.signal.aborted && controller.signal.reason instanceof PublisherError) throw controller.signal.reason;
4762
- throw new PublisherError("NETWORK", `request failed: ${e.message}`, { retryable: true, cause: e });
4763
- }
4764
- const text = await res.text();
4765
- let parsed = null;
4766
- if (text) {
4767
- try {
4768
- parsed = JSON.parse(text);
4769
- } catch {
4770
- parsed = null;
4771
- }
4772
- }
4773
- if (res.ok) return { status: res.status, body: parsed, headers: res.headers };
4774
- const err = parsed?.error;
4775
- const code = err && ERROR_CODES.includes(err.code) ? err.code : res.status >= 500 ? "UNAVAILABLE" : "INVALID_REQUEST";
4776
- throw new PublisherError(code, err?.message ?? `HTTP ${res.status}`, {
4777
- retryable: err?.retryable ?? (res.status === 429 || res.status >= 500),
4778
- httpStatus: res.status,
4779
- ...err?.details ? { details: err.details } : {}
4780
- });
4781
- } finally {
4782
- clearTimeout(timer);
4783
- ro.signal?.removeEventListener("abort", onAbort);
4784
- }
4785
- }
4786
- };
4787
- function sleep(ms, signal) {
4788
- return new Promise((resolve3, reject) => {
4789
- const t = setTimeout(resolve3, ms);
4790
- signal?.addEventListener(
4791
- "abort",
4792
- () => {
4793
- clearTimeout(t);
4794
- reject(new PublisherError("CANCELLED", "cancelled"));
4795
- },
4796
- { once: true }
4797
- );
4798
- });
4799
- }
4800
-
4801
4926
  // ../client/src/upload.ts
4802
4927
  var import_node_fs4 = require("node:fs");
4803
4928
  var import_node_stream = require("node:stream");
@@ -5231,6 +5356,19 @@ var Publisher = class {
5231
5356
  fetchImpl;
5232
5357
  tokenFn;
5233
5358
  clientInfo;
5359
+ /**
5360
+ * Follow the run an accepted publication started, at the portal that serves this endpoint, with
5361
+ * this publisher's own credential and its one refresh — the portal verifies the same token the
5362
+ * control API does.
5363
+ */
5364
+ async followRun(input, opts = {}) {
5365
+ return followRun(input, {
5366
+ ...opts,
5367
+ token: this.tokenFn,
5368
+ refresh: async () => !this.opts.token && refreshStoredCredential(this.endpoint, { fetch: this.fetchImpl }),
5369
+ fetch: this.fetchImpl
5370
+ });
5371
+ }
5234
5372
  async getCapabilities(call = {}) {
5235
5373
  const r = await this.transport.request("GET", "/v1/capabilities", void 0, call);
5236
5374
  return r.body;
@@ -5556,7 +5694,7 @@ function resolveChoice(answer, names) {
5556
5694
  }
5557
5695
  return names.find((n) => n.toLowerCase() === a.toLowerCase()) ?? null;
5558
5696
  }
5559
- async function ask(question) {
5697
+ async function ask2(question) {
5560
5698
  const rl = (0, import_promises10.createInterface)({ input: process.stdin, output: process.stderr });
5561
5699
  try {
5562
5700
  return await rl.question(question);
@@ -5574,7 +5712,7 @@ ${purpose}
5574
5712
  names.forEach((n, i) => process.stderr.write(` ${i + 1}) ${n}
5575
5713
  `));
5576
5714
  for (let attempt = 0; attempt < 3; attempt++) {
5577
- const answer = await ask(`
5715
+ const answer = await ask2(`
5578
5716
  Which one? [1-${names.length}, or a name] `);
5579
5717
  if (answer === null) return null;
5580
5718
  const chosen = resolveChoice(answer, names);
@@ -5590,7 +5728,7 @@ Name this connection, so you never have to type ${endpoint} again.
5590
5728
  (for example: prod, dev, staging)
5591
5729
  `);
5592
5730
  for (let attempt = 0; attempt < 3; attempt++) {
5593
- const answer = await ask(suggested ? `
5731
+ const answer = await ask2(suggested ? `
5594
5732
  Profile name [${suggested}]: ` : `
5595
5733
  Profile name: `);
5596
5734
  if (answer === null) return null;
@@ -5613,6 +5751,7 @@ var OPTIONS = {
5613
5751
  repo: { type: "string" },
5614
5752
  message: { type: "string" },
5615
5753
  publication: { type: "string" },
5754
+ timeout: { type: "string" },
5616
5755
  branch: { type: "string" },
5617
5756
  ours: { type: "string" },
5618
5757
  theirs: { type: "string" },
@@ -5693,6 +5832,66 @@ async function installSkills({ log }) {
5693
5832
  };
5694
5833
  }
5695
5834
 
5835
+ // src/follow.ts
5836
+ async function followPublication(p, endpoint, o, opts) {
5837
+ const derived = portalForEndpoint(endpoint);
5838
+ const confirmed = derived === null ? null : await confirmPortal(derived, endpoint, opts.fetch);
5839
+ const portal = confirmed === null ? null : derived;
5840
+ const elsewhere = (reason) => ({ kind: "unavailable", reason, portal, hint: inPortal(portal, "Published.", "Follow it") });
5841
+ if (portal === null || confirmed === null) return elsewhere("no-portal");
5842
+ if (!confirmed.terminal.includes(RUN_PROGRESS)) {
5843
+ const noRoute = { kind: "unavailable", reason: "no-route" };
5844
+ return { ...noRoute, portal, hint: progressHint(noRoute, portal) };
5845
+ }
5846
+ const refs = Object.entries(o.refs ?? {});
5847
+ if (refs.length !== 1) return elsewhere("several-refs");
5848
+ const [ref, commit] = refs[0];
5849
+ opts.log("Following it: received -> checked -> going live -> live. Ctrl-C stops watching; the publication is already saved.");
5850
+ const outcome = await p.followRun(
5851
+ { portal, repoId: o.repoId, publicationId: o.publicationId, ref, commit },
5852
+ { onStage: opts.onStage, ...opts.timeoutMs === void 0 ? {} : { timeoutMs: opts.timeoutMs } }
5853
+ );
5854
+ const next = outcome.kind === "timed-out" ? { action: "follow", command: `publisher follow --publication ${o.publicationId} --json` } : void 0;
5855
+ return { ...outcome, portal, hint: progressHint(outcome, portal), ...next === void 0 ? {} : { next } };
5856
+ }
5857
+ function followTimeoutMs(flag) {
5858
+ if (flag === void 0) return FOLLOW_TIMEOUT_MS;
5859
+ if (!/^[1-9][0-9]*$/.test(flag)) throw new PublisherError("INVALID_REQUEST", `--timeout takes a whole number of seconds, not ${JSON.stringify(flag)}`);
5860
+ return Math.min(Number(flag) * 1e3, FOLLOW_TIMEOUT_MS);
5861
+ }
5862
+ function stageLine(e, json) {
5863
+ return json ? JSON.stringify({ event: "stage", stage: e.stage, word: e.word, state: e.state, at: e.at }) : ` ${e.word}: ${e.state.replace("-", " ")}`;
5864
+ }
5865
+ function inPortal(portal, lead, verb) {
5866
+ return `${lead} ${verb} in the portal${portal === null ? "" : `: ${portal}`}.`;
5867
+ }
5868
+ function progressHint(r, portal) {
5869
+ switch (r.kind) {
5870
+ case "settled":
5871
+ switch (r.ending) {
5872
+ case "live":
5873
+ return "Your app is live.";
5874
+ case "not-deployed":
5875
+ return "Published and checked; this organisation does not deploy automatically.";
5876
+ case "failed":
5877
+ return inPortal(portal, `It stopped at "${r.failedAt ?? "a step"}".`, "See why");
5878
+ case "stopped":
5879
+ return "A newer publication replaced this one before it went live.";
5880
+ }
5881
+ break;
5882
+ case "timed-out":
5883
+ return inPortal(portal, `Still going${r.lastError === null ? "" : ` (${r.lastError})`}; run next.command to keep following.`, "Or follow it");
5884
+ case "unavailable":
5885
+ return inPortal(portal, "Published. This portal cannot show progress in the terminal yet.", "Follow it");
5886
+ case "unauthorized":
5887
+ return inPortal(portal, "Published, but the portal did not accept this sign-in (run publisher login).", "Follow it");
5888
+ case "forbidden":
5889
+ return inPortal(portal, "Published, but the portal would not show this sign-in the run.", "Follow it");
5890
+ case "malformed":
5891
+ return inPortal(portal, `Published, but the portal's answer about the run could not be read (${r.detail}).`, "Follow it");
5892
+ }
5893
+ }
5894
+
5696
5895
  // src/main.ts
5697
5896
  var USAGE = `publisher \u2014 publish an app's Git history to the serverless Git backend
5698
5897
 
@@ -5723,6 +5922,12 @@ Usage:
5723
5922
  nothing published and nothing lost. No git needed
5724
5923
  [--mark-conflicts] on a conflict, put both versions in each file, marked,
5725
5924
  to edit; the next sync records and publishes the edit
5925
+ [--no-wait] don't follow the app going live afterwards (received ->
5926
+ checked -> going live -> live, up to 15 minutes)
5927
+ [--timeout <seconds>] follow for at most this long; if it is still going, the
5928
+ result's next.command picks it up
5929
+ publisher follow [dir] --publication <id> follow an accepted publication's run where a sync left it
5930
+ [--timeout <seconds>]
5726
5931
  publisher merge-check [dir] --ours <ref> --theirs <ref> [--apply]
5727
5932
  publisher info [dir] repository state as seen by the server
5728
5933
  publisher doctor environment check (node, git, credentials, association)
@@ -5767,6 +5972,10 @@ async function main(argv) {
5767
5972
  if (e.phase === "done") process.stderr.write("\n");
5768
5973
  };
5769
5974
  const dirArg = (i) => (0, import_node_path8.resolve)(cmd[i] ?? ".");
5975
+ const emitStage = (e) => {
5976
+ if (json) process.stdout.write(stageLine(e, true) + "\n");
5977
+ else if (!quiet) process.stderr.write(stageLine(e, false) + "\n");
5978
+ };
5770
5979
  const resolutionInput = async (dir) => {
5771
5980
  let association;
5772
5981
  if (dir && await isGitRepo(dir)) association = (await readAssociation(dir))?.endpoint;
@@ -6045,9 +6254,11 @@ Waiting (it expires in ${Math.round(expiresInSeconds / 60)} minutes)...
6045
6254
  */
6046
6255
  case "sync": {
6047
6256
  const dir = dirArg(1);
6048
- const { p } = await client(dir);
6257
+ const timeoutMs = followTimeoutMs(str2(flags["timeout"]));
6258
+ const { p, endpoint } = await client(dir);
6049
6259
  const r = await p.syncChanges({ directory: dir, markConflicts: flags["mark-conflicts"] === true, ...optStr(flags, "message") }, { onProgress: progress });
6050
6260
  const o = r.published;
6261
+ const followed = o !== null && o.state === "accepted" && flags["no-wait"] !== true ? await followPublication(p, endpoint, o, { onStage: emitStage, log, timeoutMs }) : null;
6051
6262
  out({
6052
6263
  ok: r.state === "published" || r.state === "up_to_date",
6053
6264
  state: r.state,
@@ -6063,10 +6274,31 @@ Waiting (it expires in ${Math.round(expiresInSeconds / 60)} minutes)...
6063
6274
  rejection: o.rejection,
6064
6275
  ...o.next === null ? {} : { next: o.next }
6065
6276
  },
6066
- hint: syncHint(r.state, r.reconciled.before.conflicts, r.marked)
6277
+ hint: syncHint(r.state, r.reconciled.before.conflicts, r.marked),
6278
+ progress: followed
6067
6279
  });
6068
6280
  return r.state === "published" || r.state === "up_to_date" ? 0 : r.state === "unknown" ? 3 : 2;
6069
6281
  }
6282
+ /**
6283
+ * Pick up following a run where a bounded `sync --timeout` (or an interrupted one) left it. The
6284
+ * ref and commit come from git-publisher's own record of the publication, so the command needs
6285
+ * only its id. Each call reports the stages as they now stand, from the first.
6286
+ */
6287
+ case "follow": {
6288
+ const dir = dirArg(1);
6289
+ const publicationId = str2(flags["publication"]);
6290
+ if (!publicationId) throw new PublisherError("INVALID_REQUEST", "follow requires --publication <id>");
6291
+ const timeoutMs = followTimeoutMs(str2(flags["timeout"]));
6292
+ const { p, endpoint } = await client(dir);
6293
+ const repoId = str2(flags["repo"]) ?? (await readAssociation(dir))?.repoId;
6294
+ if (!repoId) throw new PublisherError("LOCAL_STATE", "no repository association; pass --repo");
6295
+ const publication = await p.getPublicationStatus(repoId, publicationId);
6296
+ if (publication.state !== "accepted") {
6297
+ throw new PublisherError("LOCAL_STATE", `publication ${publicationId} is ${publication.state}, not accepted; run publisher status`);
6298
+ }
6299
+ out({ publicationId, progress: await followPublication(p, endpoint, publication, { onStage: emitStage, log, timeoutMs }) });
6300
+ return 0;
6301
+ }
6070
6302
  case "skills": {
6071
6303
  if (cmd[1] !== "install") throw new PublisherError("INVALID_REQUEST", `unknown skills command: ${cmd[1] ?? "(none)"}; expected install`);
6072
6304
  const r = await installSkills({ log });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@minnovate/shadow-publisher",
3
- "version": "0.5.0",
3
+ "version": "0.6.1",
4
4
  "description": "publisher: publish an app's Git history to the serverless Git backend",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",