@magland/mochi 0.3.1 → 0.4.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.
- package/README.md +7 -6
- package/dist/agefile.js +47 -0
- package/dist/agescript.js +343 -0
- package/dist/api/admin.js +66 -28
- package/dist/api/auth.js +3 -2
- package/dist/api/backup.js +227 -192
- package/dist/api/contents.js +46 -16
- package/dist/api/repos.js +60 -4
- package/dist/api/write.js +138 -8
- package/dist/api.js +34 -1
- package/dist/browse.js +171 -33
- package/dist/build-info.json +2 -2
- package/dist/ci/api.js +138 -4
- package/dist/ci/artifacts.js +82 -2
- package/dist/ci/engine.js +30 -17
- package/dist/ci/expr.js +14 -0
- package/dist/ci/runners.js +60 -1
- package/dist/ci/runs.js +21 -3
- package/dist/ci/trigger.js +6 -4
- package/dist/ci/views.js +24 -3
- package/dist/ci/web.js +48 -0
- package/dist/cli/admin-cmd.js +40 -3
- package/dist/cli/backup-cmd.js +166 -120
- package/dist/cli/gitrun.js +50 -0
- package/dist/cli/pr-cmd.js +155 -0
- package/dist/cli/repo-cmd.js +134 -6
- package/dist/cli/sync-cmd.js +128 -0
- package/dist/cli/target.js +7 -3
- package/dist/config.js +10 -0
- package/dist/credentials.js +9 -8
- package/dist/deploy-cli.js +214 -109
- package/dist/deploy-runner-cli.js +125 -49
- package/dist/domains.js +259 -0
- package/dist/egress.js +23 -6
- package/dist/forms.js +703 -32
- package/dist/git.js +124 -13
- package/dist/githttp.js +80 -23
- package/dist/githubauth.js +185 -0
- package/dist/icons.js +14 -0
- package/dist/import-cli.js +40 -25
- package/dist/index.js +110 -4
- package/dist/issueweb.js +10 -6
- package/dist/lfs.js +9 -2
- package/dist/limit.js +2 -2
- package/dist/logincodes.js +31 -0
- package/dist/maintenance.js +297 -0
- package/dist/markdown.js +193 -3
- package/dist/mdedit.js +29 -0
- package/dist/migrate.js +48 -0
- package/dist/naming.js +25 -0
- package/dist/onetime.js +112 -0
- package/dist/ops.js +293 -16
- package/dist/pagescript.js +355 -24
- package/dist/perms.js +1 -0
- package/dist/profile.js +2 -0
- package/dist/pulls.js +21 -1
- package/dist/pullweb.js +25 -6
- package/dist/releases.js +10 -1
- package/dist/runner/actions.js +12 -1
- package/dist/runner/client.js +40 -2
- package/dist/runner/docker.js +40 -28
- package/dist/runner/download.js +77 -0
- package/dist/runner/externals.js +12 -1
- package/dist/runner/job.js +26 -11
- package/dist/runner/steps.js +42 -7
- package/dist/runner-cli.js +89 -0
- package/dist/scan.js +58 -2
- package/dist/server.js +107 -26
- package/dist/session.js +82 -40
- package/dist/site.js +110 -27
- package/dist/sitepublish.js +177 -0
- package/dist/sitesettings.js +420 -0
- package/dist/siteshost.js +116 -28
- package/dist/source.js +20 -0
- package/dist/style.js +162 -3
- package/dist/topics.js +165 -0
- package/dist/vault.js +399 -6
- package/dist/vendor-age.js +15 -0
- package/dist/views.js +242 -57
- package/dist/web.js +11 -1
- package/dist/webauthn.js +347 -0
- package/dist/webops.js +1263 -33
- package/package.json +4 -4
package/dist/ci/artifacts.js
CHANGED
|
@@ -39,6 +39,8 @@ exports.artifactsDir = artifactsDir;
|
|
|
39
39
|
exports.artifactPath = artifactPath;
|
|
40
40
|
exports.listArtifacts = listArtifacts;
|
|
41
41
|
exports.deploySite = deploySite;
|
|
42
|
+
exports.pruneDeployedArtifacts = pruneDeployedArtifacts;
|
|
43
|
+
exports.installSiteFromTar = installSiteFromTar;
|
|
42
44
|
const child_process_1 = require("child_process");
|
|
43
45
|
const fs = __importStar(require("fs"));
|
|
44
46
|
const path = __importStar(require("path"));
|
|
@@ -122,6 +124,11 @@ function run(cmd, args) {
|
|
|
122
124
|
// path is checked to be inside it, and only then does the new site replace
|
|
123
125
|
// the old one by rename. A half-extracted archive therefore never becomes the
|
|
124
126
|
// live site, and a crafted archive cannot write outside the vault.
|
|
127
|
+
//
|
|
128
|
+
// Once the site is in place the artifact is deleted. The site directory is
|
|
129
|
+
// its copy, and keeping both doubled the cost of every deploy for as long as
|
|
130
|
+
// the run was retained: on a vault that deploys on every push, superseded
|
|
131
|
+
// site archives were most of the disk.
|
|
125
132
|
async function deploySite(root, collection, repo, n, artifactName) {
|
|
126
133
|
if (!(0, scan_1.isValidName)(collection) || !(0, scan_1.isValidName)(repo))
|
|
127
134
|
throw new ArtifactError('invalid repository');
|
|
@@ -129,6 +136,79 @@ async function deploySite(root, collection, repo, n, artifactName) {
|
|
|
129
136
|
if (!tar || !fs.existsSync(tar)) {
|
|
130
137
|
throw new ArtifactError(`no artifact named ${artifactName} in run #${n}`);
|
|
131
138
|
}
|
|
139
|
+
const result = await installSiteFromTar(root, collection, repo, tar, {
|
|
140
|
+
unwrapPagesArtifact: true,
|
|
141
|
+
what: `artifact ${artifactName}`,
|
|
142
|
+
});
|
|
143
|
+
fs.rmSync(tar, { force: true });
|
|
144
|
+
return result;
|
|
145
|
+
}
|
|
146
|
+
// The name of the artifact a deploy-pages step published, when a completed
|
|
147
|
+
// job shows one that succeeded. The step states line up with the steps, one
|
|
148
|
+
// for one, as the runner reports them. A name written as an expression cannot
|
|
149
|
+
// be resolved after the fact, so such a step answers nothing and its artifact
|
|
150
|
+
// is left alone.
|
|
151
|
+
function deployedArtifactNames(job) {
|
|
152
|
+
const names = [];
|
|
153
|
+
job.steps.forEach((raw, i) => {
|
|
154
|
+
const step = (raw ?? {});
|
|
155
|
+
if (typeof step.uses !== 'string' || !/^actions\/deploy-pages(@|$)/i.test(step.uses.trim()))
|
|
156
|
+
return;
|
|
157
|
+
if (job.stepStates[i]?.conclusion !== 'success')
|
|
158
|
+
return;
|
|
159
|
+
const given = step.with?.artifact_name;
|
|
160
|
+
const name = typeof given === 'string' && given.trim() !== '' ? given.trim() : 'github-pages';
|
|
161
|
+
if (!name.includes('${{') && isValidArtifactName(name))
|
|
162
|
+
names.push(name);
|
|
163
|
+
});
|
|
164
|
+
return names;
|
|
165
|
+
}
|
|
166
|
+
/**
|
|
167
|
+
* Delete the artifacts that completed runs have already deployed as the
|
|
168
|
+
* repository's site, and return how many bytes that freed.
|
|
169
|
+
*
|
|
170
|
+
* deploySite now deletes its artifact itself, so this is for runs from before
|
|
171
|
+
* it did, and for a deletion that did not happen (the server stopping between
|
|
172
|
+
* the swap and the rm). Only runs that are completed are touched, and only
|
|
173
|
+
* artifacts a deploy-pages step reported publishing: an artifact that was
|
|
174
|
+
* uploaded and never deployed is left for retention to decide.
|
|
175
|
+
*/
|
|
176
|
+
function pruneDeployedArtifacts(root, collection, repo) {
|
|
177
|
+
let freed = 0;
|
|
178
|
+
for (const r of (0, runs_1.listRuns)(root, collection, repo)) {
|
|
179
|
+
if (r.status !== 'completed')
|
|
180
|
+
continue;
|
|
181
|
+
for (const jobId of r.jobs) {
|
|
182
|
+
const job = (0, runs_1.readJob)(root, collection, repo, r.number, jobId);
|
|
183
|
+
if (!job || job.status !== 'completed')
|
|
184
|
+
continue;
|
|
185
|
+
for (const name of deployedArtifactNames(job)) {
|
|
186
|
+
const tar = artifactPath(root, collection, repo, r.number, name);
|
|
187
|
+
if (!tar)
|
|
188
|
+
continue;
|
|
189
|
+
try {
|
|
190
|
+
freed += fs.statSync(tar).size;
|
|
191
|
+
fs.rmSync(tar, { force: true });
|
|
192
|
+
}
|
|
193
|
+
catch {
|
|
194
|
+
// already gone
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
return freed;
|
|
200
|
+
}
|
|
201
|
+
/**
|
|
202
|
+
* Replace a repository's site directory with the contents of a tar archive:
|
|
203
|
+
* extracted into a scratch directory beside the site, checked for symlinks
|
|
204
|
+
* that would escape it, and swapped into place in one rename with the
|
|
205
|
+
* previous site kept until the swap has succeeded. The same path publishes a
|
|
206
|
+
* workflow's artifact (above) and a 'repository' site's tree
|
|
207
|
+
* (src/sitepublish.ts); the archive is untrusted in both cases.
|
|
208
|
+
*/
|
|
209
|
+
async function installSiteFromTar(root, collection, repo, tar, opts) {
|
|
210
|
+
if (!(0, scan_1.isValidName)(collection) || !(0, scan_1.isValidName)(repo))
|
|
211
|
+
throw new ArtifactError('invalid repository');
|
|
132
212
|
const dir = (0, layout_1.repoPath)(root, collection, `${(0, scan_1.displayName)(repo)}.site`);
|
|
133
213
|
const scratch = `${dir}.incoming-${process.pid}`;
|
|
134
214
|
const previous = `${dir}.previous-${process.pid}`;
|
|
@@ -147,7 +227,7 @@ async function deploySite(root, collection, repo, n, artifactName) {
|
|
|
147
227
|
// artifact holding exactly one artifact.tar is unwrapped once more. This
|
|
148
228
|
// is what makes the real actions/upload-pages-artifact work unchanged.
|
|
149
229
|
const entries = fs.readdirSync(scratch);
|
|
150
|
-
if (entries.length === 1 && entries[0] === 'artifact.tar') {
|
|
230
|
+
if (opts.unwrapPagesArtifact && entries.length === 1 && entries[0] === 'artifact.tar') {
|
|
151
231
|
const inner = path.join(scratch, 'artifact.tar');
|
|
152
232
|
const unwrapped = `${scratch}.inner`;
|
|
153
233
|
fs.rmSync(unwrapped, { recursive: true, force: true });
|
|
@@ -178,7 +258,7 @@ async function deploySite(root, collection, repo, n, artifactName) {
|
|
|
178
258
|
};
|
|
179
259
|
walk(scratch);
|
|
180
260
|
if (files === 0)
|
|
181
|
-
throw new ArtifactError(
|
|
261
|
+
throw new ArtifactError(`${opts.what} contains no files`);
|
|
182
262
|
fs.rmSync(previous, { recursive: true, force: true });
|
|
183
263
|
const had = fs.existsSync(dir);
|
|
184
264
|
if (had)
|
package/dist/ci/engine.js
CHANGED
|
@@ -98,18 +98,21 @@ async function discoverWorkflows(repo, sha) {
|
|
|
98
98
|
continue;
|
|
99
99
|
if (!/\.(yml|yaml)$/i.test(e.name))
|
|
100
100
|
continue;
|
|
101
|
-
byBase.set(e.name, { path: `${dir}/${e.name}
|
|
101
|
+
byBase.set(e.name, { path: `${dir}/${e.name}`, size: e.size ?? 0 });
|
|
102
102
|
}
|
|
103
103
|
}
|
|
104
104
|
const out = [];
|
|
105
|
-
for (const [, { path: p }] of [...byBase.entries()].sort((a, b) => a[0].localeCompare(b[0]))) {
|
|
105
|
+
for (const [, { path: p, size }] of [...byBase.entries()].sort((a, b) => a[0].localeCompare(b[0]))) {
|
|
106
|
+
// Judged from the listing's size, before the file is read: a workflow
|
|
107
|
+
// file the size of the machine would otherwise be held in memory to be
|
|
108
|
+
// found too large.
|
|
109
|
+
if (size > MAX_WORKFLOW_SIZE) {
|
|
110
|
+
out.push({ path: p, source: '', error: 'workflow file is too large' });
|
|
111
|
+
continue;
|
|
112
|
+
}
|
|
106
113
|
let source;
|
|
107
114
|
try {
|
|
108
115
|
const buf = await repo.catBlob(sha, p);
|
|
109
|
-
if (buf.length > MAX_WORKFLOW_SIZE) {
|
|
110
|
-
out.push({ path: p, source: '', error: 'workflow file is too large' });
|
|
111
|
-
continue;
|
|
112
|
-
}
|
|
113
116
|
source = buf.toString('utf8');
|
|
114
117
|
}
|
|
115
118
|
catch {
|
|
@@ -687,8 +690,6 @@ class CiEngine {
|
|
|
687
690
|
this.maybeCompleteRun(ar);
|
|
688
691
|
}
|
|
689
692
|
jobCondition(ar, j, needs, st) {
|
|
690
|
-
if (j.if === undefined)
|
|
691
|
-
return st.allSucceeded;
|
|
692
693
|
const env = {
|
|
693
694
|
contexts: {
|
|
694
695
|
github: this.githubContext(ar.run, ar.collection, ar.repo, j.key, ''),
|
|
@@ -703,7 +704,7 @@ class CiEngine {
|
|
|
703
704
|
always: () => true,
|
|
704
705
|
},
|
|
705
706
|
};
|
|
706
|
-
return (0, expr_1.
|
|
707
|
+
return (0, expr_1.evalGatedCondition)(j.if, st.allSucceeded, env);
|
|
707
708
|
}
|
|
708
709
|
completeJob(ar, j, conclusion) {
|
|
709
710
|
j.status = 'completed';
|
|
@@ -780,17 +781,22 @@ class CiEngine {
|
|
|
780
781
|
}
|
|
781
782
|
return null;
|
|
782
783
|
}
|
|
783
|
-
// Try to lease a job now; returns null when nothing matches.
|
|
784
|
-
|
|
784
|
+
// Try to lease a job now; returns null when nothing matches. `limitMinutes`
|
|
785
|
+
// is the runner's own ceiling on how long a job may run there; see leaseJob.
|
|
786
|
+
acquire(runnerName, labels, allow, serverUrl, limitMinutes) {
|
|
785
787
|
const found = this.runnableJob(labels, allow);
|
|
786
788
|
if (!found)
|
|
787
789
|
return null;
|
|
788
|
-
return this.leaseJob(found.ar, found.job, runnerName, serverUrl);
|
|
790
|
+
return this.leaseJob(found.ar, found.job, runnerName, serverUrl, limitMinutes);
|
|
789
791
|
}
|
|
790
792
|
// Lease one chosen job to a runner (registered or manual session) and build
|
|
791
793
|
// the spec it executes from. The caller has already decided the job may go
|
|
792
794
|
// to this runner; everything from here on is the same for both kinds.
|
|
793
|
-
|
|
795
|
+
//
|
|
796
|
+
// `limitMinutes` is the longest the runner taking the job allows one to run,
|
|
797
|
+
// or null for a manual session, which has no registration to carry one and a
|
|
798
|
+
// person watching it instead.
|
|
799
|
+
leaseJob(ar, job, runnerName, serverUrl, limitMinutes) {
|
|
794
800
|
job.status = 'running';
|
|
795
801
|
job.attempts += 1;
|
|
796
802
|
job.startedAt = job.startedAt ?? nowIso();
|
|
@@ -823,6 +829,13 @@ class CiEngine {
|
|
|
823
829
|
// default stands
|
|
824
830
|
}
|
|
825
831
|
}
|
|
832
|
+
// The runner's limit is a ceiling on what the workflow asked for, not a
|
|
833
|
+
// default it could override: the job runs on somebody else's machine, and
|
|
834
|
+
// six hours of a hung step is the operator's cost. A job asking for less
|
|
835
|
+
// than the ceiling keeps what it asked for, which is what makes
|
|
836
|
+
// `timeout-minutes` still worth writing.
|
|
837
|
+
if (typeof limitMinutes === 'number')
|
|
838
|
+
timeout = Math.min(timeout, limitMinutes);
|
|
826
839
|
// A private repository cannot be cloned anonymously, so a job for one
|
|
827
840
|
// carries an ephemeral read token for exactly that repository (see
|
|
828
841
|
// src/jobtoken.ts), living a little longer than the job may run. A public
|
|
@@ -866,8 +879,8 @@ class CiEngine {
|
|
|
866
879
|
// then released again with nobody to run it. A runner stopped and started
|
|
867
880
|
// within one poll would otherwise see its first job cancelled before it
|
|
868
881
|
// could ask for one.
|
|
869
|
-
waitForJob(runnerName, labels, allow, serverUrl, timeoutMs, gone) {
|
|
870
|
-
const first = this.acquire(runnerName, labels, allow, serverUrl);
|
|
882
|
+
waitForJob(runnerName, labels, allow, serverUrl, limitMinutes, timeoutMs, gone) {
|
|
883
|
+
const first = this.acquire(runnerName, labels, allow, serverUrl, limitMinutes);
|
|
871
884
|
if (first)
|
|
872
885
|
return Promise.resolve(first);
|
|
873
886
|
if (gone?.aborted)
|
|
@@ -878,7 +891,7 @@ class CiEngine {
|
|
|
878
891
|
resolve(null);
|
|
879
892
|
}, timeoutMs);
|
|
880
893
|
const onWake = () => {
|
|
881
|
-
const spec = this.acquire(runnerName, labels, allow, serverUrl);
|
|
894
|
+
const spec = this.acquire(runnerName, labels, allow, serverUrl, limitMinutes);
|
|
882
895
|
if (spec) {
|
|
883
896
|
cleanup();
|
|
884
897
|
resolve(spec);
|
|
@@ -1039,7 +1052,7 @@ class CiEngine {
|
|
|
1039
1052
|
// the moment the job completes, and who ran a job is a fact about the
|
|
1040
1053
|
// job. Set before leaseJob so its save persists it.
|
|
1041
1054
|
j.manual = { user: grant.mintedBy, host: grant.host ?? '' };
|
|
1042
|
-
return { kind: 'job', spec: this.leaseJob(ar, j, name, serverUrl) };
|
|
1055
|
+
return { kind: 'job', spec: this.leaseJob(ar, j, name, serverUrl, null) };
|
|
1043
1056
|
}
|
|
1044
1057
|
}
|
|
1045
1058
|
const queued = matching.some((j) => j.status === 'queued');
|
package/dist/ci/expr.js
CHANGED
|
@@ -20,6 +20,7 @@ exports.render = render;
|
|
|
20
20
|
exports.renderDeep = renderDeep;
|
|
21
21
|
exports.evalCondition = evalCondition;
|
|
22
22
|
exports.mentionsStatusOverride = mentionsStatusOverride;
|
|
23
|
+
exports.evalGatedCondition = evalGatedCondition;
|
|
23
24
|
class ExprError extends Error {
|
|
24
25
|
}
|
|
25
26
|
exports.ExprError = ExprError;
|
|
@@ -524,3 +525,16 @@ function evalCondition(cond, env) {
|
|
|
524
525
|
function mentionsStatusOverride(cond) {
|
|
525
526
|
return /\b(always|failure|cancelled)\s*\(/.test(cond);
|
|
526
527
|
}
|
|
528
|
+
// An `if:` on a step or a job, judged with the status of what came before it.
|
|
529
|
+
// GitHub prepends an implicit `success() &&` unless the condition itself
|
|
530
|
+
// names a status function, so once something has failed a plain
|
|
531
|
+
// `if: github.ref == 'refs/heads/main'` skips however the comparison comes
|
|
532
|
+
// out, while `if: always()` and `if: failure()` are judged on their own
|
|
533
|
+
// terms. An absent condition is the bare gate.
|
|
534
|
+
function evalGatedCondition(cond, succeeded, env) {
|
|
535
|
+
if (cond === undefined)
|
|
536
|
+
return succeeded;
|
|
537
|
+
if (!succeeded && !mentionsStatusOverride(cond))
|
|
538
|
+
return false;
|
|
539
|
+
return evalCondition(cond, env);
|
|
540
|
+
}
|
package/dist/ci/runners.js
CHANGED
|
@@ -33,12 +33,15 @@ var __importStar = (this && this.__importStar) || (function () {
|
|
|
33
33
|
};
|
|
34
34
|
})();
|
|
35
35
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
|
-
exports.RUNNERS_FILE = void 0;
|
|
36
|
+
exports.MAX_JOB_TIMEOUT_MINUTES = exports.DEFAULT_JOB_TIMEOUT_MINUTES = exports.RUNNERS_FILE = void 0;
|
|
37
|
+
exports.runnerJobTimeout = runnerJobTimeout;
|
|
38
|
+
exports.isUsableJobTimeout = isUsableJobTimeout;
|
|
37
39
|
exports.runnersFilePath = runnersFilePath;
|
|
38
40
|
exports.loadRunners = loadRunners;
|
|
39
41
|
exports.hashRunnerToken = hashRunnerToken;
|
|
40
42
|
exports.registerRunner = registerRunner;
|
|
41
43
|
exports.regenerateRunnerToken = regenerateRunnerToken;
|
|
44
|
+
exports.setRunnerJobTimeout = setRunnerJobTimeout;
|
|
42
45
|
exports.setRunnerWake = setRunnerWake;
|
|
43
46
|
exports.removeRunner = removeRunner;
|
|
44
47
|
exports.noteRunnerSeen = noteRunnerSeen;
|
|
@@ -60,6 +63,32 @@ const vault_1 = require("../vault");
|
|
|
60
63
|
//
|
|
61
64
|
// Only a hash of each runner token is stored, as with user tokens.
|
|
62
65
|
exports.RUNNERS_FILE = 'runners.json';
|
|
66
|
+
/**
|
|
67
|
+
* How long a job may run on a runner that has not been given a limit.
|
|
68
|
+
*
|
|
69
|
+
* Twenty minutes rather than GitHub's six hours because the failure this bounds
|
|
70
|
+
* is a job that hangs: a step waiting on input that never comes, a network call
|
|
71
|
+
* with no timeout of its own. On a hosted runner that costs someone else's
|
|
72
|
+
* money; on a machine an operator registered here it costs theirs, and holds
|
|
73
|
+
* the runner against every other job in the meantime. A build that genuinely
|
|
74
|
+
* takes longer is a runner whose limit is raised, deliberately and once.
|
|
75
|
+
*/
|
|
76
|
+
exports.DEFAULT_JOB_TIMEOUT_MINUTES = 20;
|
|
77
|
+
/** The longest a job may run on this runner, whether it was set or defaulted. */
|
|
78
|
+
function runnerJobTimeout(runner) {
|
|
79
|
+
return runner.jobTimeoutMinutes ?? exports.DEFAULT_JOB_TIMEOUT_MINUTES;
|
|
80
|
+
}
|
|
81
|
+
/** Four weeks: past this a millisecond delay overflows a signed 32-bit field. */
|
|
82
|
+
exports.MAX_JOB_TIMEOUT_MINUTES = 40320;
|
|
83
|
+
/**
|
|
84
|
+
* Whether a number may be stored as a runner's job timeout: whole minutes, at
|
|
85
|
+
* least one, and no more than MAX_JOB_TIMEOUT_MINUTES, past which the runner's
|
|
86
|
+
* own setTimeout would fire immediately. Refusing an absurd value is kinder
|
|
87
|
+
* than a limit that does the opposite of what it says.
|
|
88
|
+
*/
|
|
89
|
+
function isUsableJobTimeout(minutes) {
|
|
90
|
+
return Number.isInteger(minutes) && minutes >= 1 && minutes <= exports.MAX_JOB_TIMEOUT_MINUTES;
|
|
91
|
+
}
|
|
63
92
|
function runnersFilePath(root) {
|
|
64
93
|
return path.join(root, exports.RUNNERS_FILE);
|
|
65
94
|
}
|
|
@@ -91,6 +120,13 @@ function normalize(parsed) {
|
|
|
91
120
|
allow: strings(r.allow, 'allow'),
|
|
92
121
|
createdBy: typeof r.createdBy === 'string' ? r.createdBy : '',
|
|
93
122
|
createdAt: typeof r.createdAt === 'string' ? r.createdAt : '',
|
|
123
|
+
// An unusable value is dropped rather than refused, unlike the fields
|
|
124
|
+
// above whose absence would leave no runner at all: a hand-edited
|
|
125
|
+
// timeout of "soon" leaves the runner working under the default, which
|
|
126
|
+
// is the safe direction for a bound.
|
|
127
|
+
...(typeof r.jobTimeoutMinutes === 'number' && isUsableJobTimeout(r.jobTimeoutMinutes)
|
|
128
|
+
? { jobTimeoutMinutes: r.jobTimeoutMinutes }
|
|
129
|
+
: {}),
|
|
94
130
|
...(typeof r.tokenUpdatedAt === 'string' ? { tokenUpdatedAt: r.tokenUpdatedAt } : {}),
|
|
95
131
|
...(typeof r.wakeUrl === 'string' ? { wakeUrl: r.wakeUrl } : {}),
|
|
96
132
|
...(typeof r.wakeSecret === 'string' ? { wakeSecret: r.wakeSecret } : {}),
|
|
@@ -149,6 +185,7 @@ function registerRunner(root, name, opts) {
|
|
|
149
185
|
allow: opts.allow,
|
|
150
186
|
createdBy: opts.createdBy,
|
|
151
187
|
createdAt: new Date().toISOString(),
|
|
188
|
+
...(opts.jobTimeoutMinutes ? { jobTimeoutMinutes: opts.jobTimeoutMinutes } : {}),
|
|
152
189
|
...(opts.wake ? { wakeUrl: opts.wake.url, wakeSecret: opts.wake.secret } : {}),
|
|
153
190
|
};
|
|
154
191
|
registry.runners[name] = runner;
|
|
@@ -180,6 +217,28 @@ function regenerateRunnerToken(root, name) {
|
|
|
180
217
|
return { token, runner };
|
|
181
218
|
});
|
|
182
219
|
}
|
|
220
|
+
/**
|
|
221
|
+
* Set the longest a job may run on this runner, or put it back on the default
|
|
222
|
+
* with null. Separate from registration, as the wake address is, because it is
|
|
223
|
+
* the setting most likely to be changed after the fact: the first job that
|
|
224
|
+
* runs long is when an operator learns the limit is wrong. Returns null if no
|
|
225
|
+
* runner by that name is registered.
|
|
226
|
+
*/
|
|
227
|
+
function setRunnerJobTimeout(root, name, minutes) {
|
|
228
|
+
return editRunners(root, () => {
|
|
229
|
+
const registry = loadRunners(root);
|
|
230
|
+
const existing = registry.runners[name];
|
|
231
|
+
if (!existing)
|
|
232
|
+
return null;
|
|
233
|
+
const runner = { ...existing };
|
|
234
|
+
delete runner.jobTimeoutMinutes;
|
|
235
|
+
if (minutes !== null)
|
|
236
|
+
runner.jobTimeoutMinutes = minutes;
|
|
237
|
+
registry.runners[name] = runner;
|
|
238
|
+
write(root, registry);
|
|
239
|
+
return runner;
|
|
240
|
+
});
|
|
241
|
+
}
|
|
183
242
|
/**
|
|
184
243
|
* Point a runner's wake address somewhere, or clear it with null.
|
|
185
244
|
*
|
package/dist/ci/runs.js
CHANGED
|
@@ -33,6 +33,7 @@ var __importStar = (this && this.__importStar) || (function () {
|
|
|
33
33
|
};
|
|
34
34
|
})();
|
|
35
35
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
|
+
exports.MIN_RUN_AGE_MS = void 0;
|
|
36
37
|
exports.runsDir = runsDir;
|
|
37
38
|
exports.createRun = createRun;
|
|
38
39
|
exports.readRun = readRun;
|
|
@@ -185,9 +186,19 @@ function listRuns(root, collection, repoName) {
|
|
|
185
186
|
runs.sort((a, b) => b.number - a.number);
|
|
186
187
|
return runs;
|
|
187
188
|
}
|
|
189
|
+
/**
|
|
190
|
+
* A completed run is kept for at least this long, whatever the retention
|
|
191
|
+
* settings say. A low run count is otherwise at the mercy of a busy hour: a
|
|
192
|
+
* repository whose workflows fire on every push could lose the run somebody is
|
|
193
|
+
* still reading, or the one that failed, to the twenty that followed it. Timed
|
|
194
|
+
* from completion, since a run is history from then on. A constant rather than
|
|
195
|
+
* a setting, for the reason the sweep's are (see src/maintenance.ts).
|
|
196
|
+
*/
|
|
197
|
+
exports.MIN_RUN_AGE_MS = 24 * 60 * 60 * 1000;
|
|
188
198
|
// Delete completed runs beyond the retention settings. Active runs are never
|
|
189
|
-
// pruned regardless of age
|
|
190
|
-
|
|
199
|
+
// pruned regardless of age, and neither is a run completed in the last
|
|
200
|
+
// MIN_RUN_AGE_MS, so `runs` is a floor that a busy day may exceed.
|
|
201
|
+
function pruneRuns(root, collection, repoName, retain, now = Date.now()) {
|
|
191
202
|
const base = runsDir(root, collection, repoName);
|
|
192
203
|
if (!base)
|
|
193
204
|
return;
|
|
@@ -196,12 +207,19 @@ function pruneRuns(root, collection, repoName, retain) {
|
|
|
196
207
|
const doomed = new Set();
|
|
197
208
|
completed.slice(Math.max(0, retain.runs)).forEach((r) => doomed.add(r.number));
|
|
198
209
|
if (retain.days > 0) {
|
|
199
|
-
const cutoff =
|
|
210
|
+
const cutoff = now - retain.days * 24 * 60 * 60 * 1000;
|
|
200
211
|
for (const r of completed) {
|
|
201
212
|
if (new Date(r.createdAt).getTime() < cutoff)
|
|
202
213
|
doomed.add(r.number);
|
|
203
214
|
}
|
|
204
215
|
}
|
|
216
|
+
for (const r of completed) {
|
|
217
|
+
const finished = new Date(r.completedAt ?? r.createdAt).getTime();
|
|
218
|
+
// An unparseable time is treated as old, so a damaged record cannot pin
|
|
219
|
+
// its run forever.
|
|
220
|
+
if (Number.isFinite(finished) && now - finished < exports.MIN_RUN_AGE_MS)
|
|
221
|
+
doomed.delete(r.number);
|
|
222
|
+
}
|
|
205
223
|
for (const n of doomed) {
|
|
206
224
|
fs.rmSync(path.join(base, String(n)), { recursive: true, force: true });
|
|
207
225
|
}
|
package/dist/ci/trigger.js
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.firePush = firePush;
|
|
4
4
|
const scan_1 = require("../scan");
|
|
5
|
+
const sitepublish_1 = require("../sitepublish");
|
|
5
6
|
// One place that turns a branch having moved into a workflow event.
|
|
6
7
|
//
|
|
7
8
|
// A commit made in the browser, a commit made over the API, and a merge are all
|
|
@@ -17,12 +18,13 @@ const ZERO = '0'.repeat(40);
|
|
|
17
18
|
* it, which by this point has already been committed.
|
|
18
19
|
*/
|
|
19
20
|
function firePush(root, engine, repo, branch, before, after, actor) {
|
|
20
|
-
if (!engine)
|
|
21
|
-
return;
|
|
22
21
|
const gitRepo = (0, scan_1.findRepo)(root, repo.collection, repo.name);
|
|
23
22
|
if (!gitRepo)
|
|
24
23
|
return;
|
|
25
|
-
|
|
26
|
-
|
|
24
|
+
const ref = `refs/heads/${branch}`;
|
|
25
|
+
// The site first, then CI: a site published from the repository follows
|
|
26
|
+
// the branch whichever door the commit came through, engine or no engine.
|
|
27
|
+
(0, sitepublish_1.publishSiteAfterPush)(root, gitRepo, ref)
|
|
28
|
+
.then(() => engine?.handlePush(gitRepo, { ref, before: before ?? ZERO, after, actor }))
|
|
27
29
|
.catch((e) => console.error(`CI trigger failed: ${e instanceof Error ? e.message : e}`));
|
|
28
30
|
}
|
package/dist/ci/views.js
CHANGED
|
@@ -8,13 +8,13 @@ exports.runnerPage = runnerPage;
|
|
|
8
8
|
exports.runnerTokenPage = runnerTokenPage;
|
|
9
9
|
exports.runnerWakePage = runnerWakePage;
|
|
10
10
|
const ansi_1 = require("../ansi");
|
|
11
|
-
const avatar_1 = require("../avatar");
|
|
12
11
|
const html_1 = require("../html");
|
|
13
12
|
const icons_1 = require("../icons");
|
|
14
13
|
const render_1 = require("../render");
|
|
15
14
|
const forms_1 = require("../forms");
|
|
16
15
|
const views_1 = require("../views");
|
|
17
16
|
const manual_1 = require("./manual");
|
|
17
|
+
const runners_1 = require("./runners");
|
|
18
18
|
function statusOf(x) {
|
|
19
19
|
if (x.status !== 'completed')
|
|
20
20
|
return x.status === 'running' ? 'running' : 'queued';
|
|
@@ -80,7 +80,7 @@ function runsPage(ctx, runs, workflows, selectedWorkflow, flash) {
|
|
|
80
80
|
: '';
|
|
81
81
|
return (0, html_1.html) `<tr>
|
|
82
82
|
<td class="run-cell">${statusIcon(s)}<span><a href="${actionsBase}/runs/${r.number}"><b>${runTitle(r)}</b></a>
|
|
83
|
-
<div class="muted small run-sub">${r.workflowName} #${r.number}: ${r.event} by ${(0,
|
|
83
|
+
<div class="muted small run-sub">${r.workflowName} #${r.number}: ${r.event} by ${(0, views_1.userLink)(r.actor, { face: 16 })}${sha}</div></span></td>
|
|
84
84
|
<td class="right muted small"><span class="chip">${(0, icons_1.icon)('git-branch')}${r.refName}</span></td>
|
|
85
85
|
<td class="right muted small">${when}${dur ? (0, html_1.html) ` · ${dur}` : ''}</td>
|
|
86
86
|
</tr>`;
|
|
@@ -266,7 +266,7 @@ ${summaryBox}`;
|
|
|
266
266
|
<div class="run-meta muted small">
|
|
267
267
|
<a href="${actionsBase}?workflow=${encodeURIComponent(run.workflowPath)}">${run.workflowName}</a>
|
|
268
268
|
· #${run.number}
|
|
269
|
-
· ${run.event} by <span class="run-actor">${(0,
|
|
269
|
+
· ${run.event} by <span class="run-actor">${(0, views_1.userLink)(run.actor, { face: 16 })}</span>
|
|
270
270
|
· <span class="chip">${run.refName}</span>
|
|
271
271
|
${run.sha ? (0, html_1.html) `· <a class="sha" href="${base}/commit/${run.sha}">${run.sha.slice(0, 7)}</a>` : ''}
|
|
272
272
|
· <a href="${base}/blob/${(0, views_1.encPath)(run.refName)}/${(0, views_1.encPath)(run.workflowPath)}">${run.workflowPath}</a>
|
|
@@ -297,6 +297,14 @@ ${(0, views_1.copyRow)(command)}
|
|
|
297
297
|
</div>`;
|
|
298
298
|
return (0, views_1.layout)(`Run it yourself - ${ctx.collection}/${ctx.repo}`, content, (0, views_1.repoOpts)(ctx, back));
|
|
299
299
|
}
|
|
300
|
+
/** Minutes as an operator reads them: 90 minutes is worth saying as 1h 30m. */
|
|
301
|
+
function minutesLabel(minutes) {
|
|
302
|
+
if (minutes < 60)
|
|
303
|
+
return `${minutes} minute${minutes === 1 ? '' : 's'}`;
|
|
304
|
+
const h = Math.floor(minutes / 60);
|
|
305
|
+
const m = minutes % 60;
|
|
306
|
+
return m === 0 ? `${h} hour${h === 1 ? '' : 's'}` : `${h}h ${m}m`;
|
|
307
|
+
}
|
|
300
308
|
// A runner is either working on something, idle but in touch, or not there at
|
|
301
309
|
// all. The third case is the one an operator is usually looking for, so it gets
|
|
302
310
|
// a plain word rather than an empty cell.
|
|
@@ -338,6 +346,8 @@ ${(0, views_1.csrfField)(viewer)}
|
|
|
338
346
|
<p class="muted small">Matched against a job's <code>runs-on</code>. Space or comma separated.</p></div>
|
|
339
347
|
<div class="field"><label for="allow">Repositories</label><input type="text" id="allow" name="allow" placeholder="mycollection/*" required>
|
|
340
348
|
<p class="muted small">Globs over <code>collection/repo</code>. A runner executes whatever those repositories' workflows say, on the machine you start it on, so grant it only what you trust.</p></div>
|
|
349
|
+
<div class="field"><label for="minutes">Job timeout</label><input type="number" id="minutes" name="minutes" min="1" max="${String(runners_1.MAX_JOB_TIMEOUT_MINUTES)}" step="1" placeholder="${String(runners_1.DEFAULT_JOB_TIMEOUT_MINUTES)}">
|
|
350
|
+
<p class="muted small">Minutes a single job may run here, ${String(runners_1.DEFAULT_JOB_TIMEOUT_MINUTES)} by default. A ceiling on what a job's own <code>timeout-minutes</code> may ask for, and changeable afterwards.</p></div>
|
|
341
351
|
<button type="submit" class="btn btn-primary">Register runner</button>
|
|
342
352
|
</form>
|
|
343
353
|
</div>`;
|
|
@@ -356,6 +366,7 @@ ${fact('Labels', r.labels.length
|
|
|
356
366
|
${fact('Repositories', (0, html_1.html) `<span class="mono">${r.allow.join(' ')}</span>`)}
|
|
357
367
|
${fact('Registered', (0, html_1.html) `by ${r.createdBy}${r.createdAt ? (0, html_1.html) ` ${(0, render_1.timeTag)(r.createdAt, '')}` : ''}`)}
|
|
358
368
|
${fact('Token', r.tokenUpdatedAt ? (0, html_1.html) `regenerated ${(0, render_1.timeTag)(r.tokenUpdatedAt, '')}` : (0, html_1.html) `the one issued at registration`)}
|
|
369
|
+
${fact('Job timeout', (0, html_1.html) `${minutesLabel(r.jobTimeout)}${r.jobTimeoutSet ? '' : (0, html_1.html) ` <span class="counter">default</span>`}`)}
|
|
359
370
|
${fact('Wake', r.wakeUrl ? (0, html_1.html) `<span class="mono">${r.wakeUrl}</span>` : (0, html_1.raw)('<span class="muted">nothing starts this runner</span>'))}
|
|
360
371
|
</div>`;
|
|
361
372
|
const content = (0, html_1.html) `<div class="page-head"><h1>${(0, icons_1.icon)('server', 'icon')}${r.name}</h1></div>
|
|
@@ -372,6 +383,16 @@ ${(0, views_1.copyRow)(`mochi runner run --host ${host} --runner-token <token>`)
|
|
|
372
383
|
: 'nothing yet'}.</p>
|
|
373
384
|
</div>
|
|
374
385
|
<div class="form-box wide" style="margin-top:24px">
|
|
386
|
+
<h2>Job timeout</h2>
|
|
387
|
+
<p class="muted">The longest a single job may run on ${r.name}. It is a ceiling, not a default: a job whose <code>timeout-minutes</code> asks for less keeps what it asked for, and one asking for more, or asking for nothing, is held to this. Reaching it removes the job's container, which fails the step that was running and the job with it.</p>
|
|
388
|
+
<form method="post" action="/admin/runners/${encodeURIComponent(r.name)}/job-timeout">
|
|
389
|
+
${(0, views_1.csrfField)(viewer)}
|
|
390
|
+
<div class="field"><label for="jobTimeout">Minutes</label><input type="number" id="jobTimeout" name="minutes" min="1" max="${String(runners_1.MAX_JOB_TIMEOUT_MINUTES)}" step="1" value="${r.jobTimeoutSet ? String(r.jobTimeout) : ''}" placeholder="${String(runners_1.DEFAULT_JOB_TIMEOUT_MINUTES)}">
|
|
391
|
+
<p class="muted small">Empty means the vault's default of ${String(runners_1.DEFAULT_JOB_TIMEOUT_MINUTES)} minutes. A change applies to the next job this runner takes; one already running keeps the timeout it started with.</p></div>
|
|
392
|
+
<button type="submit" class="btn">${(0, icons_1.icon)('clock')}<span>Save job timeout</span></button>
|
|
393
|
+
</form>
|
|
394
|
+
</div>
|
|
395
|
+
<div class="form-box wide" style="margin-top:24px">
|
|
375
396
|
<h2>Wake address</h2>
|
|
376
397
|
<p class="muted">A runner started with <code>--idle</code> stops when it has had no job for that long, which is how a runner on hardware billed by the minute stops costing anything between runs. It cannot be told that work has arrived, though, so the vault sends a request to this address instead, and whatever is in front of the runner (a Fly proxy, a socket unit) starts it. The request carries a secret and nothing else; a new one is generated when you save an address, and the runner has to be started with it.</p>
|
|
377
398
|
<p class="muted">Sent at most once a minute per runner, however many jobs are waiting, and only when the runner has not been heard from.</p>
|
package/dist/ci/web.js
CHANGED
|
@@ -424,6 +424,8 @@ function registerCiWeb(app, root, engine) {
|
|
|
424
424
|
createdBy: r.createdBy,
|
|
425
425
|
createdAt: r.createdAt,
|
|
426
426
|
tokenUpdatedAt: r.tokenUpdatedAt,
|
|
427
|
+
jobTimeout: (0, runners_1.runnerJobTimeout)(r),
|
|
428
|
+
jobTimeoutSet: r.jobTimeoutMinutes !== undefined,
|
|
427
429
|
lastSeen: (0, runners_1.runnerLastSeen)(name),
|
|
428
430
|
running: load.running[name] ?? null,
|
|
429
431
|
wakeUrl: r.wakeUrl ?? null,
|
|
@@ -495,10 +497,16 @@ function registerCiWeb(app, root, engine) {
|
|
|
495
497
|
showError(`'${manual_1.MANUAL_LABEL}' is a reserved label: a job that names it runs only through a command pasted from its run page, never on a registered runner.`);
|
|
496
498
|
return;
|
|
497
499
|
}
|
|
500
|
+
const minutes = jobTimeoutField(req);
|
|
501
|
+
if (minutes === 'bad') {
|
|
502
|
+
showError(`A job timeout is a whole number of minutes from 1 to ${runners_1.MAX_JOB_TIMEOUT_MINUTES}, or empty for the default of ${runners_1.DEFAULT_JOB_TIMEOUT_MINUTES}.`);
|
|
503
|
+
return;
|
|
504
|
+
}
|
|
498
505
|
const { token } = (0, runners_1.registerRunner)(root, name, {
|
|
499
506
|
labels: labels.length ? labels : ['ubuntu-latest'],
|
|
500
507
|
allow,
|
|
501
508
|
createdBy: viewer.auth.username,
|
|
509
|
+
jobTimeoutMinutes: minutes,
|
|
502
510
|
});
|
|
503
511
|
res.type('html').send(ciViews.runnerTokenPage(viewer, name, token, (0, web_1.baseUrlOf)(req)));
|
|
504
512
|
});
|
|
@@ -523,6 +531,46 @@ function registerCiWeb(app, root, engine) {
|
|
|
523
531
|
}
|
|
524
532
|
res.type('html').send(ciViews.runnerTokenPage(viewer, name, issued.token, (0, web_1.baseUrlOf)(req), true));
|
|
525
533
|
});
|
|
534
|
+
/**
|
|
535
|
+
* The job timeout a form carried: a number of minutes, null for the default
|
|
536
|
+
* when the field is empty, or 'bad' for anything else. Empty is a real
|
|
537
|
+
* answer here rather than a missing one, since the field is always rendered.
|
|
538
|
+
*/
|
|
539
|
+
function jobTimeoutField(req) {
|
|
540
|
+
const raw = (0, web_1.field)(req, 'minutes').trim();
|
|
541
|
+
if (raw === '')
|
|
542
|
+
return null;
|
|
543
|
+
if (!/^\d{1,7}$/.test(raw))
|
|
544
|
+
return 'bad';
|
|
545
|
+
const n = parseInt(raw, 10);
|
|
546
|
+
return (0, runners_1.isUsableJobTimeout)(n) ? n : 'bad';
|
|
547
|
+
}
|
|
548
|
+
app.post('/admin/runners/:name/job-timeout', form, (req, res) => {
|
|
549
|
+
const viewer = (0, web_1.requireViewerPost)(root, req, res);
|
|
550
|
+
if (!viewer)
|
|
551
|
+
return;
|
|
552
|
+
if (!(0, session_1.viewerIsAdmin)(viewer)) {
|
|
553
|
+
(0, web_1.fail)(res, 403, 'Admin access is required to manage runners.', viewer);
|
|
554
|
+
return;
|
|
555
|
+
}
|
|
556
|
+
const name = req.params.name;
|
|
557
|
+
const existing = (0, runners_1.loadRunners)(root).runners[name];
|
|
558
|
+
if (!existing) {
|
|
559
|
+
(0, web_1.send404)(res, `No runner named ${name} is registered.`, viewer);
|
|
560
|
+
return;
|
|
561
|
+
}
|
|
562
|
+
const back = `/admin/runners/${encodeURIComponent(name)}`;
|
|
563
|
+
const minutes = jobTimeoutField(req);
|
|
564
|
+
if (minutes === 'bad') {
|
|
565
|
+
(0, web_1.fail)(res, 400, `A job timeout is a whole number of minutes from 1 to ${runners_1.MAX_JOB_TIMEOUT_MINUTES}, or empty for the default of ${runners_1.DEFAULT_JOB_TIMEOUT_MINUTES}.`, viewer, back);
|
|
566
|
+
return;
|
|
567
|
+
}
|
|
568
|
+
(0, runners_1.setRunnerJobTimeout)(root, name, minutes);
|
|
569
|
+
const flash = minutes === null
|
|
570
|
+
? `${name} is back on the default job timeout of ${runners_1.DEFAULT_JOB_TIMEOUT_MINUTES} minutes.`
|
|
571
|
+
: `Jobs on ${name} now stop after ${minutes} minute${minutes === 1 ? '' : 's'}.`;
|
|
572
|
+
res.redirect(`${back}?flash=${encodeURIComponent(flash)}`);
|
|
573
|
+
});
|
|
526
574
|
// Setting the address and sending a request to it are separate posts
|
|
527
575
|
// because they are separate acts: one changes what is stored, the other
|
|
528
576
|
// starts a machine and waits for it. A single form doing both would make
|
package/dist/cli/admin-cmd.js
CHANGED
|
@@ -151,11 +151,48 @@ rename is undone by renaming back.`,
|
|
|
151
151
|
console.log('Remotes pointing at the old name, and token scopes naming it, need changing.');
|
|
152
152
|
},
|
|
153
153
|
},
|
|
154
|
+
{
|
|
155
|
+
path: ['collection', 'edit'],
|
|
156
|
+
summary: "Change a collection's site alias",
|
|
157
|
+
description: `The alias is the label that stands in for the collection's name in each of its
|
|
158
|
+
repositories' site hostnames, <repo>--<alias>.<sites host>. Empty means the
|
|
159
|
+
collection's own name where that name is usable as a hostname label, and the
|
|
160
|
+
name rewritten as one otherwise (simulated_instruments becomes
|
|
161
|
+
simulated-instruments), the rewrite being skipped when another collection
|
|
162
|
+
already holds the label it would produce.
|
|
163
|
+
|
|
164
|
+
Takes ownership of the collection. An alias another collection is already
|
|
165
|
+
reached by is refused, naming it. Changing the alias moves every site in the
|
|
166
|
+
collection to a new origin, and the hostnames they had stop resolving.`,
|
|
167
|
+
args: [{ name: 'name', required: true }],
|
|
168
|
+
options: [
|
|
169
|
+
{ name: 'site-alias', type: 'string', value: '<l>', summary: "Alias under the sites host; '' for the default" },
|
|
170
|
+
output_1.JSON_OPTION,
|
|
171
|
+
...target_1.TARGET_OPTIONS,
|
|
172
|
+
],
|
|
173
|
+
async run(inv) {
|
|
174
|
+
const alias = inv.str('site-alias');
|
|
175
|
+
if (alias === null)
|
|
176
|
+
throw new exit_1.CliError('Nothing to change. Pass --site-alias.', exit_1.EXIT_USAGE);
|
|
177
|
+
const target = await (0, target_1.targetFrom)(inv);
|
|
178
|
+
const data = await (0, cli_api_1.api)(target, 'PATCH', `/api/collections/${encodeURIComponent(inv.args[0])}`, {
|
|
179
|
+
siteAlias: alias,
|
|
180
|
+
});
|
|
181
|
+
const json = (0, output_1.jsonMode)(inv);
|
|
182
|
+
if (json.enabled) {
|
|
183
|
+
(0, output_1.printJson)((0, output_1.pickObject)(data, json.fields));
|
|
184
|
+
return;
|
|
185
|
+
}
|
|
186
|
+
console.log(data.alias
|
|
187
|
+
? `Sites in ${data.name} are served under <repo>--${data.alias}`
|
|
188
|
+
: `${data.name} has no site alias, so nothing in it has a derived hostname`);
|
|
189
|
+
},
|
|
190
|
+
},
|
|
154
191
|
{
|
|
155
192
|
path: ['collection', 'delete'],
|
|
156
193
|
summary: 'Remove an empty collection',
|
|
157
|
-
description: `
|
|
158
|
-
|
|
194
|
+
description: `Only an empty collection: one holding any repository is refused rather than
|
|
195
|
+
emptied. The collection's own owners list goes with it.`,
|
|
159
196
|
args: [{ name: 'name', required: true }],
|
|
160
197
|
options: [YES_OPTION, output_1.JSON_OPTION, ...target_1.TARGET_OPTIONS],
|
|
161
198
|
async run(inv) {
|
|
@@ -239,7 +276,7 @@ the running server had not made. Edit config.json in the vault and restart.`,
|
|
|
239
276
|
value: '<host>',
|
|
240
277
|
summary: "Hostname whose subdomains serve sites; '' to serve them sandboxed on the forge host",
|
|
241
278
|
},
|
|
242
|
-
{ name: 'ci-runs', type: 'int', value: '<n>', summary: 'Completed runs to keep per repository' },
|
|
279
|
+
{ name: 'ci-runs', type: 'int', value: '<n>', summary: 'Completed runs to keep per repository (runs from the last 24 hours are always kept)' },
|
|
243
280
|
{ name: 'ci-days', type: 'int', value: '<n>', summary: 'Also drop runs older than this; 0 disables' },
|
|
244
281
|
{ name: 'ci-artifact-mb', type: 'int', value: '<n>', summary: 'Largest artifact a job may upload' },
|
|
245
282
|
{
|