@minnovate/shadow-publisher 0.5.0 → 0.6.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.
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "git-publisher",
|
|
3
3
|
"displayName": "Git Publisher",
|
|
4
|
-
"version": "0.
|
|
4
|
+
"version": "0.6.0",
|
|
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
|
|
92
|
-
|
|
93
|
-
|
|
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)
|
|
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
|
package/dist/publisher.cjs
CHANGED
|
@@ -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
|
|
3589
|
-
if (
|
|
3590
|
-
const { stdout } = await runGit(["cat-file", "--batch-check=%(objectname)", "-Z"], { cwd: dir, input:
|
|
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
|
|
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,233 @@ 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 && isRecord(answer.body) && typeof answer.body["error"] === "string") return { kind: "unauthorized" };
|
|
4650
|
+
if (answer.status === 404 || answer.status === 401) 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
|
+
async function confirmPortal(portal, endpoint, fetchImpl = fetch) {
|
|
4708
|
+
try {
|
|
4709
|
+
const res = await fetchImpl(`${portal}/api/v1/config`, { signal: AbortSignal.timeout(1e4) });
|
|
4710
|
+
if (!res.ok) return false;
|
|
4711
|
+
const body = await res.json();
|
|
4712
|
+
const named = body?.publishingEndpoint;
|
|
4713
|
+
const bare = (u) => u.replace(/\/+$/, "");
|
|
4714
|
+
return typeof named === "string" && bare(named) === bare(endpoint);
|
|
4715
|
+
} catch {
|
|
4716
|
+
return false;
|
|
4717
|
+
}
|
|
4718
|
+
}
|
|
4719
|
+
function isRecord(value) {
|
|
4720
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
4721
|
+
}
|
|
4722
|
+
|
|
4496
4723
|
// ../client/src/onboard.ts
|
|
4497
4724
|
var import_promises6 = require("node:fs/promises");
|
|
4498
4725
|
var import_node_path5 = require("node:path");
|
|
@@ -4693,111 +4920,6 @@ var import_node_crypto4 = require("node:crypto");
|
|
|
4693
4920
|
// ../client/src/publish.ts
|
|
4694
4921
|
var import_promises7 = require("node:fs/promises");
|
|
4695
4922
|
|
|
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
4923
|
// ../client/src/upload.ts
|
|
4802
4924
|
var import_node_fs4 = require("node:fs");
|
|
4803
4925
|
var import_node_stream = require("node:stream");
|
|
@@ -5231,6 +5353,19 @@ var Publisher = class {
|
|
|
5231
5353
|
fetchImpl;
|
|
5232
5354
|
tokenFn;
|
|
5233
5355
|
clientInfo;
|
|
5356
|
+
/**
|
|
5357
|
+
* Follow the run an accepted publication started, at the portal that serves this endpoint, with
|
|
5358
|
+
* this publisher's own credential and its one refresh — the portal verifies the same token the
|
|
5359
|
+
* control API does.
|
|
5360
|
+
*/
|
|
5361
|
+
async followRun(input, opts = {}) {
|
|
5362
|
+
return followRun(input, {
|
|
5363
|
+
...opts,
|
|
5364
|
+
token: this.tokenFn,
|
|
5365
|
+
refresh: async () => !this.opts.token && refreshStoredCredential(this.endpoint, { fetch: this.fetchImpl }),
|
|
5366
|
+
fetch: this.fetchImpl
|
|
5367
|
+
});
|
|
5368
|
+
}
|
|
5234
5369
|
async getCapabilities(call = {}) {
|
|
5235
5370
|
const r = await this.transport.request("GET", "/v1/capabilities", void 0, call);
|
|
5236
5371
|
return r.body;
|
|
@@ -5556,7 +5691,7 @@ function resolveChoice(answer, names) {
|
|
|
5556
5691
|
}
|
|
5557
5692
|
return names.find((n) => n.toLowerCase() === a.toLowerCase()) ?? null;
|
|
5558
5693
|
}
|
|
5559
|
-
async function
|
|
5694
|
+
async function ask2(question) {
|
|
5560
5695
|
const rl = (0, import_promises10.createInterface)({ input: process.stdin, output: process.stderr });
|
|
5561
5696
|
try {
|
|
5562
5697
|
return await rl.question(question);
|
|
@@ -5574,7 +5709,7 @@ ${purpose}
|
|
|
5574
5709
|
names.forEach((n, i) => process.stderr.write(` ${i + 1}) ${n}
|
|
5575
5710
|
`));
|
|
5576
5711
|
for (let attempt = 0; attempt < 3; attempt++) {
|
|
5577
|
-
const answer = await
|
|
5712
|
+
const answer = await ask2(`
|
|
5578
5713
|
Which one? [1-${names.length}, or a name] `);
|
|
5579
5714
|
if (answer === null) return null;
|
|
5580
5715
|
const chosen = resolveChoice(answer, names);
|
|
@@ -5590,7 +5725,7 @@ Name this connection, so you never have to type ${endpoint} again.
|
|
|
5590
5725
|
(for example: prod, dev, staging)
|
|
5591
5726
|
`);
|
|
5592
5727
|
for (let attempt = 0; attempt < 3; attempt++) {
|
|
5593
|
-
const answer = await
|
|
5728
|
+
const answer = await ask2(suggested ? `
|
|
5594
5729
|
Profile name [${suggested}]: ` : `
|
|
5595
5730
|
Profile name: `);
|
|
5596
5731
|
if (answer === null) return null;
|
|
@@ -5613,6 +5748,7 @@ var OPTIONS = {
|
|
|
5613
5748
|
repo: { type: "string" },
|
|
5614
5749
|
message: { type: "string" },
|
|
5615
5750
|
publication: { type: "string" },
|
|
5751
|
+
timeout: { type: "string" },
|
|
5616
5752
|
branch: { type: "string" },
|
|
5617
5753
|
ours: { type: "string" },
|
|
5618
5754
|
theirs: { type: "string" },
|
|
@@ -5693,6 +5829,61 @@ async function installSkills({ log }) {
|
|
|
5693
5829
|
};
|
|
5694
5830
|
}
|
|
5695
5831
|
|
|
5832
|
+
// src/follow.ts
|
|
5833
|
+
async function followPublication(p, endpoint, o, opts) {
|
|
5834
|
+
const derived = portalForEndpoint(endpoint);
|
|
5835
|
+
const portal = derived !== null && await confirmPortal(derived, endpoint, opts.fetch) ? derived : null;
|
|
5836
|
+
const elsewhere = (reason) => ({ kind: "unavailable", reason, portal, hint: inPortal(portal, "Published.", "Follow it") });
|
|
5837
|
+
if (portal === null) return elsewhere("no-portal");
|
|
5838
|
+
const refs = Object.entries(o.refs ?? {});
|
|
5839
|
+
if (refs.length !== 1) return elsewhere("several-refs");
|
|
5840
|
+
const [ref, commit] = refs[0];
|
|
5841
|
+
opts.log("Following it: received -> checked -> going live -> live. Ctrl-C stops watching; the publication is already saved.");
|
|
5842
|
+
const outcome = await p.followRun(
|
|
5843
|
+
{ portal, repoId: o.repoId, publicationId: o.publicationId, ref, commit },
|
|
5844
|
+
{ onStage: opts.onStage, ...opts.timeoutMs === void 0 ? {} : { timeoutMs: opts.timeoutMs } }
|
|
5845
|
+
);
|
|
5846
|
+
const next = outcome.kind === "timed-out" ? { action: "follow", command: `publisher follow --publication ${o.publicationId} --json` } : void 0;
|
|
5847
|
+
return { ...outcome, portal, hint: progressHint(outcome, portal), ...next === void 0 ? {} : { next } };
|
|
5848
|
+
}
|
|
5849
|
+
function followTimeoutMs(flag) {
|
|
5850
|
+
if (flag === void 0) return FOLLOW_TIMEOUT_MS;
|
|
5851
|
+
if (!/^[1-9][0-9]*$/.test(flag)) throw new PublisherError("INVALID_REQUEST", `--timeout takes a whole number of seconds, not ${JSON.stringify(flag)}`);
|
|
5852
|
+
return Math.min(Number(flag) * 1e3, FOLLOW_TIMEOUT_MS);
|
|
5853
|
+
}
|
|
5854
|
+
function stageLine(e, json) {
|
|
5855
|
+
return json ? JSON.stringify({ event: "stage", stage: e.stage, word: e.word, state: e.state, at: e.at }) : ` ${e.word}: ${e.state.replace("-", " ")}`;
|
|
5856
|
+
}
|
|
5857
|
+
function inPortal(portal, lead, verb) {
|
|
5858
|
+
return `${lead} ${verb} in the portal${portal === null ? "" : `: ${portal}`}.`;
|
|
5859
|
+
}
|
|
5860
|
+
function progressHint(r, portal) {
|
|
5861
|
+
switch (r.kind) {
|
|
5862
|
+
case "settled":
|
|
5863
|
+
switch (r.ending) {
|
|
5864
|
+
case "live":
|
|
5865
|
+
return "Your app is live.";
|
|
5866
|
+
case "not-deployed":
|
|
5867
|
+
return "Published and checked; this organisation does not deploy automatically.";
|
|
5868
|
+
case "failed":
|
|
5869
|
+
return inPortal(portal, `It stopped at "${r.failedAt ?? "a step"}".`, "See why");
|
|
5870
|
+
case "stopped":
|
|
5871
|
+
return "A newer publication replaced this one before it went live.";
|
|
5872
|
+
}
|
|
5873
|
+
break;
|
|
5874
|
+
case "timed-out":
|
|
5875
|
+
return inPortal(portal, `Still going${r.lastError === null ? "" : ` (${r.lastError})`}; run next.command to keep following.`, "Or follow it");
|
|
5876
|
+
case "unavailable":
|
|
5877
|
+
return inPortal(portal, "Published. This portal cannot show progress in the terminal yet.", "Follow it");
|
|
5878
|
+
case "unauthorized":
|
|
5879
|
+
return inPortal(portal, "Published, but the portal did not accept this sign-in (run publisher login).", "Follow it");
|
|
5880
|
+
case "forbidden":
|
|
5881
|
+
return inPortal(portal, "Published, but the portal would not show this sign-in the run.", "Follow it");
|
|
5882
|
+
case "malformed":
|
|
5883
|
+
return inPortal(portal, `Published, but the portal's answer about the run could not be read (${r.detail}).`, "Follow it");
|
|
5884
|
+
}
|
|
5885
|
+
}
|
|
5886
|
+
|
|
5696
5887
|
// src/main.ts
|
|
5697
5888
|
var USAGE = `publisher \u2014 publish an app's Git history to the serverless Git backend
|
|
5698
5889
|
|
|
@@ -5723,6 +5914,12 @@ Usage:
|
|
|
5723
5914
|
nothing published and nothing lost. No git needed
|
|
5724
5915
|
[--mark-conflicts] on a conflict, put both versions in each file, marked,
|
|
5725
5916
|
to edit; the next sync records and publishes the edit
|
|
5917
|
+
[--no-wait] don't follow the app going live afterwards (received ->
|
|
5918
|
+
checked -> going live -> live, up to 15 minutes)
|
|
5919
|
+
[--timeout <seconds>] follow for at most this long; if it is still going, the
|
|
5920
|
+
result's next.command picks it up
|
|
5921
|
+
publisher follow [dir] --publication <id> follow an accepted publication's run where a sync left it
|
|
5922
|
+
[--timeout <seconds>]
|
|
5726
5923
|
publisher merge-check [dir] --ours <ref> --theirs <ref> [--apply]
|
|
5727
5924
|
publisher info [dir] repository state as seen by the server
|
|
5728
5925
|
publisher doctor environment check (node, git, credentials, association)
|
|
@@ -5767,6 +5964,10 @@ async function main(argv) {
|
|
|
5767
5964
|
if (e.phase === "done") process.stderr.write("\n");
|
|
5768
5965
|
};
|
|
5769
5966
|
const dirArg = (i) => (0, import_node_path8.resolve)(cmd[i] ?? ".");
|
|
5967
|
+
const emitStage = (e) => {
|
|
5968
|
+
if (json) process.stdout.write(stageLine(e, true) + "\n");
|
|
5969
|
+
else if (!quiet) process.stderr.write(stageLine(e, false) + "\n");
|
|
5970
|
+
};
|
|
5770
5971
|
const resolutionInput = async (dir) => {
|
|
5771
5972
|
let association;
|
|
5772
5973
|
if (dir && await isGitRepo(dir)) association = (await readAssociation(dir))?.endpoint;
|
|
@@ -6045,9 +6246,11 @@ Waiting (it expires in ${Math.round(expiresInSeconds / 60)} minutes)...
|
|
|
6045
6246
|
*/
|
|
6046
6247
|
case "sync": {
|
|
6047
6248
|
const dir = dirArg(1);
|
|
6048
|
-
const
|
|
6249
|
+
const timeoutMs = followTimeoutMs(str2(flags["timeout"]));
|
|
6250
|
+
const { p, endpoint } = await client(dir);
|
|
6049
6251
|
const r = await p.syncChanges({ directory: dir, markConflicts: flags["mark-conflicts"] === true, ...optStr(flags, "message") }, { onProgress: progress });
|
|
6050
6252
|
const o = r.published;
|
|
6253
|
+
const followed = o !== null && o.state === "accepted" && flags["no-wait"] !== true ? await followPublication(p, endpoint, o, { onStage: emitStage, log, timeoutMs }) : null;
|
|
6051
6254
|
out({
|
|
6052
6255
|
ok: r.state === "published" || r.state === "up_to_date",
|
|
6053
6256
|
state: r.state,
|
|
@@ -6063,10 +6266,31 @@ Waiting (it expires in ${Math.round(expiresInSeconds / 60)} minutes)...
|
|
|
6063
6266
|
rejection: o.rejection,
|
|
6064
6267
|
...o.next === null ? {} : { next: o.next }
|
|
6065
6268
|
},
|
|
6066
|
-
hint: syncHint(r.state, r.reconciled.before.conflicts, r.marked)
|
|
6269
|
+
hint: syncHint(r.state, r.reconciled.before.conflicts, r.marked),
|
|
6270
|
+
progress: followed
|
|
6067
6271
|
});
|
|
6068
6272
|
return r.state === "published" || r.state === "up_to_date" ? 0 : r.state === "unknown" ? 3 : 2;
|
|
6069
6273
|
}
|
|
6274
|
+
/**
|
|
6275
|
+
* Pick up following a run where a bounded `sync --timeout` (or an interrupted one) left it. The
|
|
6276
|
+
* ref and commit come from git-publisher's own record of the publication, so the command needs
|
|
6277
|
+
* only its id. Each call reports the stages as they now stand, from the first.
|
|
6278
|
+
*/
|
|
6279
|
+
case "follow": {
|
|
6280
|
+
const dir = dirArg(1);
|
|
6281
|
+
const publicationId = str2(flags["publication"]);
|
|
6282
|
+
if (!publicationId) throw new PublisherError("INVALID_REQUEST", "follow requires --publication <id>");
|
|
6283
|
+
const timeoutMs = followTimeoutMs(str2(flags["timeout"]));
|
|
6284
|
+
const { p, endpoint } = await client(dir);
|
|
6285
|
+
const repoId = str2(flags["repo"]) ?? (await readAssociation(dir))?.repoId;
|
|
6286
|
+
if (!repoId) throw new PublisherError("LOCAL_STATE", "no repository association; pass --repo");
|
|
6287
|
+
const publication = await p.getPublicationStatus(repoId, publicationId);
|
|
6288
|
+
if (publication.state !== "accepted") {
|
|
6289
|
+
throw new PublisherError("LOCAL_STATE", `publication ${publicationId} is ${publication.state}, not accepted; run publisher status`);
|
|
6290
|
+
}
|
|
6291
|
+
out({ publicationId, progress: await followPublication(p, endpoint, publication, { onStage: emitStage, log, timeoutMs }) });
|
|
6292
|
+
return 0;
|
|
6293
|
+
}
|
|
6070
6294
|
case "skills": {
|
|
6071
6295
|
if (cmd[1] !== "install") throw new PublisherError("INVALID_REQUEST", `unknown skills command: ${cmd[1] ?? "(none)"}; expected install`);
|
|
6072
6296
|
const r = await installSkills({ log });
|