@zitadel/cli 0.3.0 → 0.4.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.
- package/README.md +39 -0
- package/dist/index.mjs +1450 -1047
- package/dist/index.mjs.map +1 -1
- package/dist/lib/oclif/help.mjs +3 -2
- package/dist/lib/oclif/help.mjs.map +1 -1
- package/dist/skills/zitadel/SKILL.md +111 -0
- package/dist/skills/zitadel-cli/SKILL.md +635 -0
- package/dist/skills/zitadel-cli/references/driving-login-ui.md +124 -0
- package/dist/skills/zitadel-components/SKILL.md +152 -0
- package/dist/skills/zitadel-sdk-angular/SKILL.md +149 -0
- package/dist/skills/zitadel-sdk-next/SKILL.md +174 -0
- package/dist/skills/zitadel-sdk-nuxt/SKILL.md +149 -0
- package/dist/skills/zitadel-sdk-qwik/SKILL.md +136 -0
- package/dist/skills/zitadel-sdk-qwik-router/SKILL.md +126 -0
- package/dist/skills/zitadel-sdk-react/SKILL.md +149 -0
- package/dist/skills/zitadel-sdk-solid/SKILL.md +144 -0
- package/dist/skills/zitadel-sdk-solid-start/SKILL.md +149 -0
- package/dist/skills/zitadel-sdk-svelte/SKILL.md +137 -0
- package/dist/skills/zitadel-sdk-sveltekit/SKILL.md +147 -0
- package/dist/skills/zitadel-sdk-tanstack-start/SKILL.md +150 -0
- package/dist/skills/zitadel-sdk-vue/SKILL.md +147 -0
- package/dist/{skills-hint-N1H5-ny1.mjs → skills-hint-C8oe8cCo.mjs} +1446 -231
- package/dist/skills-hint-C8oe8cCo.mjs.map +1 -0
- package/oclif.manifest.json +102 -3
- package/package.json +8 -5
- package/skills/zitadel-cli/SKILL.md +62 -9
- package/dist/skills-hint-N1H5-ny1.mjs.map +0 -1
|
@@ -1,15 +1,15 @@
|
|
|
1
|
+
import { Args, Flags } from "@oclif/core";
|
|
1
2
|
import consola$1, { consola } from "consola";
|
|
2
3
|
import { ApiError, NetworkError, apiErrorCode, apiErrorMessage } from "@zitadel/api/runtime/fetch";
|
|
3
4
|
import { createZitadelClient } from "@zitadel/api/client";
|
|
4
5
|
import { stringify } from "safe-stable-stringify";
|
|
5
|
-
import { copyFile, mkdtemp, readFile, readdir, rm } from "node:fs/promises";
|
|
6
|
-
import { PublishPolicyBody, publishPolicyBodyOneConfigMinLengthMax } from "@zitadel/api/generated/endpoints/zitadelNextGen.zod";
|
|
6
|
+
import { copyFile, lstat, mkdtemp, readFile, readdir, realpath, rm, stat, utimes, writeFile } from "node:fs/promises";
|
|
7
|
+
import { BuildReleaseBody, PublishPolicyBody, publishPolicyBodyOneConfigMinLengthMax } from "@zitadel/api/generated/endpoints/zitadelNextGen.zod";
|
|
7
8
|
import { randomUUID } from "node:crypto";
|
|
8
9
|
import { execFile } from "node:child_process";
|
|
9
10
|
import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
10
11
|
import { existsSync, lstatSync, mkdirSync, readFileSync, realpathSync, unlinkSync, writeFileSync } from "node:fs";
|
|
11
12
|
import { homedir, release, tmpdir } from "node:os";
|
|
12
|
-
import { promisify } from "node:util";
|
|
13
13
|
import { BlockList, isIP } from "node:net";
|
|
14
14
|
import pc from "picocolors";
|
|
15
15
|
import { Agent, getGlobalDispatcher, setGlobalDispatcher } from "undici";
|
|
@@ -66,6 +66,12 @@ var ZitadelError = class extends Error {
|
|
|
66
66
|
return EXIT_CODES[this.code] ?? 1;
|
|
67
67
|
}
|
|
68
68
|
};
|
|
69
|
+
/** What to do about a request that got no usable response, by its reason. */
|
|
70
|
+
const NETWORK_HINTS = {
|
|
71
|
+
unreachable: "Check your connection and the server URL (ZITADEL_URL or --server).",
|
|
72
|
+
timeout: "The server accepted the connection but did not answer in time. Check that it is healthy, then retry.",
|
|
73
|
+
unreadable: "The server answered, but its response could not be read. Check any proxy between you and the server, then retry."
|
|
74
|
+
};
|
|
69
75
|
/**
|
|
70
76
|
* Normalises any thrown value into a {@link ZitadelError}. Inspection is
|
|
71
77
|
* ordered most-specific-first (already-normalised, HTTP status, then
|
|
@@ -87,7 +93,7 @@ function toZitadelError(error) {
|
|
|
87
93
|
};
|
|
88
94
|
if (error.status === 404) {
|
|
89
95
|
if (apiErrorCode(error) === "req.not_found") return new ZitadelError("E_NOT_FOUND", `${error.url} has no such endpoint on this Zitadel server`, {
|
|
90
|
-
hint: "The server and this CLI are different versions. Run the CLI release that matches the server, or upgrade the server.",
|
|
96
|
+
hint: "The server and this CLI are different versions; a server from before the API moved under /api/v1 answers every call so. Run the CLI release that matches the server, or upgrade the server.",
|
|
91
97
|
details
|
|
92
98
|
});
|
|
93
99
|
return new ZitadelError("E_NOT_FOUND", !isPlatformErrorEnvelope(error.body) ? `${apiErrorMessage(error)} — ${error.url} has no such endpoint; is this a Zitadel platform API?` : apiErrorMessage(error), { details });
|
|
@@ -117,7 +123,7 @@ function toZitadelError(error) {
|
|
|
117
123
|
});
|
|
118
124
|
}
|
|
119
125
|
if (error instanceof NetworkError) return new ZitadelError("E_NETWORK", error.message, {
|
|
120
|
-
hint: error.reason
|
|
126
|
+
hint: NETWORK_HINTS[error.reason],
|
|
121
127
|
details: {
|
|
122
128
|
url: error.url,
|
|
123
129
|
reason: error.reason
|
|
@@ -354,20 +360,13 @@ function sanitizeRejection(error) {
|
|
|
354
360
|
* `timeoutMs`.
|
|
355
361
|
*/
|
|
356
362
|
function createZitadelClient$1(opts, sanitize = {}) {
|
|
357
|
-
|
|
358
|
-
const client = createZitadelClient({
|
|
363
|
+
return createZitadelClient({
|
|
359
364
|
signal: interruptSignal(),
|
|
360
365
|
timeoutMs: REQUEST_TIMEOUT_MS,
|
|
361
|
-
...opts
|
|
366
|
+
...opts,
|
|
367
|
+
...sanitize.verbatim ? {} : { onResponse: sanitizeResponse },
|
|
368
|
+
onError: sanitizeRejection
|
|
362
369
|
});
|
|
363
|
-
return new Proxy(client, { get(target, prop, receiver) {
|
|
364
|
-
const value = Reflect.get(target, prop, receiver);
|
|
365
|
-
if (typeof value !== "function") return value;
|
|
366
|
-
return (...args) => {
|
|
367
|
-
const result = value(...args);
|
|
368
|
-
return result instanceof Promise ? result.then(onResolve, sanitizeRejection) : result;
|
|
369
|
-
};
|
|
370
|
-
} });
|
|
371
370
|
}
|
|
372
371
|
//#endregion
|
|
373
372
|
//#region src/lib/json.ts
|
|
@@ -562,51 +561,6 @@ function isObject$1(value) {
|
|
|
562
561
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
563
562
|
}
|
|
564
563
|
//#endregion
|
|
565
|
-
//#region src/lib/oclif/crud/paging.ts
|
|
566
|
-
/** The list commands' own: `--all`, and `--limit` with `--page-token`. */
|
|
567
|
-
const LIST_FLAGS = {
|
|
568
|
-
all: "--all",
|
|
569
|
-
onePage: "--limit and --page-token"
|
|
570
|
-
};
|
|
571
|
-
/**
|
|
572
|
-
* Walk a cursor-paginated endpoint. `request` performs one page; the
|
|
573
|
-
* response must carry the items under `items` and, while more remain, a
|
|
574
|
-
* the cursor property `nextPageToken` names. With `all` the pages are drained
|
|
575
|
-
* in order and `next`
|
|
576
|
-
* is `null`; otherwise one page is fetched and `next` is its token. `flags`
|
|
577
|
-
* names the caller's own flags in the errors.
|
|
578
|
-
*/
|
|
579
|
-
const collectPages = async (request, { items, all, token, nextPageToken, flags = LIST_FLAGS }) => {
|
|
580
|
-
const collected = [];
|
|
581
|
-
const seen = /* @__PURE__ */ new Set();
|
|
582
|
-
let cursor = token;
|
|
583
|
-
for (;;) {
|
|
584
|
-
const response = await request(cursor);
|
|
585
|
-
const page = items === void 0 ? response : isObject$1(response) ? response[items] : void 0;
|
|
586
|
-
if (!Array.isArray(page)) throw new ZitadelError("E_VALIDATION", items === void 0 ? "Unexpected list response: expected an array" : `Unexpected list response: missing "${items}" array`, { details: { response } });
|
|
587
|
-
collected.push(...page);
|
|
588
|
-
const cursor_ = isObject$1(response) ? response[nextPageToken] : void 0;
|
|
589
|
-
const next = typeof cursor_ === "string" ? cursor_ || null : null;
|
|
590
|
-
if (!all) return {
|
|
591
|
-
items: collected,
|
|
592
|
-
next
|
|
593
|
-
};
|
|
594
|
-
if (!next) return {
|
|
595
|
-
items: collected,
|
|
596
|
-
next: null
|
|
597
|
-
};
|
|
598
|
-
if (seen.has(next)) throw new ZitadelError("E_VALIDATION", `The server repeated a page cursor, so ${flags.all} would never finish`, {
|
|
599
|
-
hint: `Fetch one page at a time with ${flags.onePage}, and report the repeated cursor.`,
|
|
600
|
-
details: {
|
|
601
|
-
cursor: next,
|
|
602
|
-
pages_fetched: seen.size + 1
|
|
603
|
-
}
|
|
604
|
-
});
|
|
605
|
-
seen.add(next);
|
|
606
|
-
cursor = next;
|
|
607
|
-
}
|
|
608
|
-
};
|
|
609
|
-
//#endregion
|
|
610
564
|
//#region src/lib/public-cli.ts
|
|
611
565
|
const CLI_PACKAGE_NAME = "@zitadel/cli";
|
|
612
566
|
function npmDistTagForCliVersion(cliVersion) {
|
|
@@ -775,6 +729,41 @@ function inShell(command, shell) {
|
|
|
775
729
|
return `${shell.before ?? ""}${command}${shell.after ?? ""}`;
|
|
776
730
|
}
|
|
777
731
|
//#endregion
|
|
732
|
+
//#region src/lib/oclif/flags.ts
|
|
733
|
+
/**
|
|
734
|
+
* Refuse a value that is present but holds nothing.
|
|
735
|
+
*
|
|
736
|
+
* `required: true` is no help: it asks whether a token arrived in argv, and an
|
|
737
|
+
* empty one satisfies it. The way this happens is the shell, not a typo —
|
|
738
|
+
* `--client-id "$CLIENT_ID"` with the variable unset sends exactly that, the
|
|
739
|
+
* expansion dropping the value while the quotes keep the word, so argv carries
|
|
740
|
+
* `["--client-id", ""]`. Unquoted, the word vanishes and oclif refuses with
|
|
741
|
+
* "expects a value"; quoted, nothing complained and the empty string travelled
|
|
742
|
+
* on as if it were a real answer.
|
|
743
|
+
*
|
|
744
|
+
* Refusing during parse rather than in each command puts it at the same point
|
|
745
|
+
* oclif's own refusal lands, so both spellings of a missing value fail the same
|
|
746
|
+
* way, and the value that reaches `run` is already trimmed.
|
|
747
|
+
*/
|
|
748
|
+
function refuseBlank(input, label, message) {
|
|
749
|
+
const value = input.trim();
|
|
750
|
+
if (value === "") throw new ZitadelError("E_VALIDATION", message, {
|
|
751
|
+
hint: `Pass a value for ${label}, or omit it entirely.`,
|
|
752
|
+
details: { argument: label }
|
|
753
|
+
});
|
|
754
|
+
return value;
|
|
755
|
+
}
|
|
756
|
+
/**
|
|
757
|
+
* A string flag whose value must not be blank. See {@link refuseBlank}.
|
|
758
|
+
*
|
|
759
|
+
* Not for a flag that writes a field value: an empty string is a legitimate
|
|
760
|
+
* thing to store, so the generated resource flags in `crud/fields.ts` keep
|
|
761
|
+
* plain `Flags.string`.
|
|
762
|
+
*/
|
|
763
|
+
const nonBlankString = Flags.custom({ parse: async (input, _command, flag) => refuseBlank(input, `--${flag.name}`, "the value is empty") });
|
|
764
|
+
/** A positional argument whose value must not be blank. See {@link refuseBlank}. */
|
|
765
|
+
const nonBlankArg = Args.custom({ parse: async (input, _command, arg) => refuseBlank(input, arg.name, `${arg.name} was given an empty value`) });
|
|
766
|
+
//#endregion
|
|
778
767
|
//#region src/lib/telemetry/dimensions/env-flag.ts
|
|
779
768
|
const DISABLED_VALUES$1 = /* @__PURE__ */ new Set([
|
|
780
769
|
"",
|
|
@@ -1111,6 +1100,69 @@ function takeReportedWarnings() {
|
|
|
1111
1100
|
return reported.splice(0);
|
|
1112
1101
|
}
|
|
1113
1102
|
//#endregion
|
|
1103
|
+
//#region src/lib/oclif/crud/table.ts
|
|
1104
|
+
/**
|
|
1105
|
+
* The machine form of a list: one record per line, fields separated by tabs,
|
|
1106
|
+
* no header and no padding, so `cut -f2` and `awk -F'\t'` work. This is what a
|
|
1107
|
+
* piped run emits — the aligned table is for a terminal, where nothing is
|
|
1108
|
+
* parsing it. Tabs and newlines inside a value would break the record, so they
|
|
1109
|
+
* are escaped rather than passed through.
|
|
1110
|
+
*/
|
|
1111
|
+
const renderRows = (columns, items) => items.map((item) => columns.map((column) => readCell(column, item).replaceAll(" ", "\\t").replaceAll("\n", "\\n")).join(" ")).join("\n");
|
|
1112
|
+
/**
|
|
1113
|
+
* Render `items` as an aligned text table, one column per dot-path in
|
|
1114
|
+
* `columns`, headed by each path's last segment. Nested paths resolve
|
|
1115
|
+
* through objects; missing values render empty; non-strings render as JSON.
|
|
1116
|
+
*/
|
|
1117
|
+
const renderTable = (columns, items) => {
|
|
1118
|
+
const headers = columns.map((column) => column.split(".").at(-1) ?? column);
|
|
1119
|
+
const rows = items.map((item) => columns.map((column) => readCell(column, item)));
|
|
1120
|
+
const widths = rows.reduce((current, row) => current.map((width, i) => Math.max(width, row[i]?.length ?? 0)), headers.map((header) => header.length));
|
|
1121
|
+
const line = (cells) => cells.map((cell, i) => cell.padEnd(widths[i] ?? 0)).join(" ").trimEnd();
|
|
1122
|
+
return [
|
|
1123
|
+
line(headers),
|
|
1124
|
+
line(widths.map((width) => "-".repeat(width))),
|
|
1125
|
+
...rows.map(line)
|
|
1126
|
+
].join("\n");
|
|
1127
|
+
};
|
|
1128
|
+
/**
|
|
1129
|
+
* The whole terminal rendering of a list, so no command re-derives the
|
|
1130
|
+
* plain/TTY/empty/footer logic (and so none forgets `--plain`): a pipe (or
|
|
1131
|
+
* `--plain`) gets tab-separated rows with no header and nothing when empty, so
|
|
1132
|
+
* `cut`/`awk`/`wc -l` work; a terminal gets the aligned table, a blank line, a
|
|
1133
|
+
* `${n} ${singular|plural}` count, and any `footer` lines. Pair it with
|
|
1134
|
+
* `listData()` for the JSON envelope.
|
|
1135
|
+
*/
|
|
1136
|
+
const renderList = (columns, rows, opts) => {
|
|
1137
|
+
if (opts.plain) return renderRows(columns, rows);
|
|
1138
|
+
if (rows.length === 0) return opts.empty;
|
|
1139
|
+
const count = `${rows.length} ${rows.length === 1 ? opts.singular : opts.plural ?? `${opts.singular}s`}`;
|
|
1140
|
+
return [
|
|
1141
|
+
renderTable(columns, rows),
|
|
1142
|
+
"",
|
|
1143
|
+
count,
|
|
1144
|
+
...opts.footer ?? []
|
|
1145
|
+
].join("\n");
|
|
1146
|
+
};
|
|
1147
|
+
/** One cell: a dot-path resolved through objects, rendered as text. */
|
|
1148
|
+
const readCell = (column, item) => {
|
|
1149
|
+
const value = column.split(".").reduce((current, key) => isObject$1(current) ? current[key] : void 0, item);
|
|
1150
|
+
return value == null ? "" : typeof value === "string" ? value : JSON.stringify(value);
|
|
1151
|
+
};
|
|
1152
|
+
/**
|
|
1153
|
+
* One record for a person: the heading first, then a labelled field per line.
|
|
1154
|
+
* Nested paths are labelled by their last segment, so `metadata.status` reads
|
|
1155
|
+
* as `status`. It is the terminal rendering of `get`; a pipe (and `--json`)
|
|
1156
|
+
* still receives the whole object, since a script wants the record, not a view
|
|
1157
|
+
* of it.
|
|
1158
|
+
*/
|
|
1159
|
+
const renderDetail = (heading, fields, item) => {
|
|
1160
|
+
const rows = fields.map((field) => [field.split(".").at(-1) ?? field, readCell(field, item)]).filter(([, value]) => value !== "");
|
|
1161
|
+
const width = Math.max(0, ...rows.map(([label]) => label.length));
|
|
1162
|
+
const body = rows.map(([label, value]) => `${label.padEnd(width)} ${value}`);
|
|
1163
|
+
return [...heading ? [heading, ""] : [], ...body].join("\n");
|
|
1164
|
+
};
|
|
1165
|
+
//#endregion
|
|
1114
1166
|
//#region src/lib/variables.ts
|
|
1115
1167
|
/** The scope a variable write or delete addresses, from the project and `--preview`. */
|
|
1116
1168
|
function variableScope(projectId, preview) {
|
|
@@ -1158,6 +1210,14 @@ function toVariableRows(values) {
|
|
|
1158
1210
|
}
|
|
1159
1211
|
/** Stands in for a secret's value, which the platform never discloses. */
|
|
1160
1212
|
const SECRET_MARKER = "(secret)";
|
|
1213
|
+
/**
|
|
1214
|
+
* The variables a deployment froze, as `deployment get` shows them: the
|
|
1215
|
+
* `variable resolve` table, indented under a heading.
|
|
1216
|
+
*/
|
|
1217
|
+
function frozenVariables(rows) {
|
|
1218
|
+
if (rows.length === 0) return " variables none";
|
|
1219
|
+
return [" variables", ...renderTable(["name", "value"], variableCells(rows)).split("\n").map((line) => ` ${line}`)].join("\n");
|
|
1220
|
+
}
|
|
1161
1221
|
/** Stands in for a kind the name holds no value for at all. */
|
|
1162
1222
|
const NO_VALUE = "-";
|
|
1163
1223
|
/**
|
|
@@ -2004,24 +2064,29 @@ function stepNamed(flow, name) {
|
|
|
2004
2064
|
* replacing it, so enabling a second one later keeps the first.
|
|
2005
2065
|
*/
|
|
2006
2066
|
function applySsoToSchema(schema, slug) {
|
|
2007
|
-
const
|
|
2008
|
-
const
|
|
2067
|
+
const copy = clone(schema);
|
|
2068
|
+
const current = "x-auth-methods" in copy ? copy["x-auth-methods"] : void 0;
|
|
2069
|
+
const methods = isObject(current) ? current : {};
|
|
2009
2070
|
const existing = isObject(methods.sso) ? methods.sso : void 0;
|
|
2010
|
-
const providers = Array.isArray(existing?.providers) ?
|
|
2071
|
+
const providers = Array.isArray(existing?.providers) ? existing.providers : [];
|
|
2011
2072
|
if (existing?.enabled === true && providers.includes(slug)) return {
|
|
2012
2073
|
document: schema,
|
|
2013
2074
|
changed: false,
|
|
2014
2075
|
skipped: []
|
|
2015
2076
|
};
|
|
2016
2077
|
if (!providers.includes(slug)) providers.push(slug);
|
|
2017
|
-
methods.sso = {
|
|
2018
|
-
...existing,
|
|
2019
|
-
enabled: true,
|
|
2020
|
-
providers
|
|
2021
|
-
};
|
|
2022
|
-
document["x-auth-methods"] = methods;
|
|
2023
2078
|
return {
|
|
2024
|
-
document
|
|
2079
|
+
document: {
|
|
2080
|
+
...copy,
|
|
2081
|
+
"x-auth-methods": {
|
|
2082
|
+
...methods,
|
|
2083
|
+
sso: {
|
|
2084
|
+
...existing,
|
|
2085
|
+
enabled: true,
|
|
2086
|
+
providers
|
|
2087
|
+
}
|
|
2088
|
+
}
|
|
2089
|
+
},
|
|
2025
2090
|
changed: true,
|
|
2026
2091
|
skipped: []
|
|
2027
2092
|
};
|
|
@@ -3402,31 +3467,44 @@ async function buildBundle(cwd) {
|
|
|
3402
3467
|
};
|
|
3403
3468
|
}
|
|
3404
3469
|
//#endregion
|
|
3405
|
-
//#region src/lib/release/
|
|
3470
|
+
//#region src/lib/release/release.ts
|
|
3406
3471
|
/**
|
|
3407
|
-
*
|
|
3408
|
-
*
|
|
3409
|
-
*
|
|
3410
|
-
*
|
|
3472
|
+
* Build a release from the `.zitadel/` bundle (`POST /releases/build`). The
|
|
3473
|
+
* server reuses the revisions that already match and allocates the rest, and
|
|
3474
|
+
* dedupes on content: re-posting the same bundle returns the release that
|
|
3475
|
+
* already carries it. (`POST /releases` pins pre-existing revision ids and is
|
|
3476
|
+
* not a CLI flow.)
|
|
3411
3477
|
*/
|
|
3412
|
-
async function
|
|
3413
|
-
const
|
|
3414
|
-
|
|
3415
|
-
|
|
3416
|
-
...
|
|
3417
|
-
|
|
3418
|
-
|
|
3419
|
-
|
|
3420
|
-
|
|
3421
|
-
|
|
3422
|
-
|
|
3423
|
-
|
|
3424
|
-
|
|
3478
|
+
async function createReleaseFromBundle(client, projectId, bundle, source) {
|
|
3479
|
+
const body = {
|
|
3480
|
+
bundle,
|
|
3481
|
+
...source.message === void 0 ? {} : { message: source.message },
|
|
3482
|
+
...source.gitSha === null ? {} : { git_sha: source.gitSha },
|
|
3483
|
+
git_dirty: source.gitDirty
|
|
3484
|
+
};
|
|
3485
|
+
assertBuildReleaseBody(body);
|
|
3486
|
+
return client.buildRelease(body, { project_id: projectId });
|
|
3487
|
+
}
|
|
3488
|
+
/** Refuses a body `POST /releases/build` does not take, naming each field it refuses. */
|
|
3489
|
+
function assertBuildReleaseBody(body) {
|
|
3490
|
+
const checked = BuildReleaseBody.safeParse(body);
|
|
3491
|
+
if (!checked.success) {
|
|
3492
|
+
const issues = checked.error.issues;
|
|
3493
|
+
const noun = issues.length === 1 ? "issue" : "issues";
|
|
3494
|
+
throw new ZitadelError("E_VALIDATION", `The release body does not match what the API takes, so nothing was sent (${issues.length} ${noun}):\n` + issues.map((issue) => ` - ${issue.path.join(".") || "(root)"} ${issue.message}`).join("\n"), { details: { issues } });
|
|
3495
|
+
}
|
|
3425
3496
|
}
|
|
3497
|
+
/** Revoke a release so its content is never served again. Idempotent. */
|
|
3498
|
+
async function revokeRelease(client, projectId, releaseId) {
|
|
3499
|
+
return client.revokeRelease(releaseId, { project_id: projectId });
|
|
3500
|
+
}
|
|
3501
|
+
//#endregion
|
|
3502
|
+
//#region src/lib/release/deployment.ts
|
|
3426
3503
|
/**
|
|
3427
3504
|
* Whether a deployment serves a primary origin. A preview deployment's targets
|
|
3428
3505
|
* carry an expiry; it is never the baseline, the guard or the thing a bare
|
|
3429
|
-
* rollback undoes.
|
|
3506
|
+
* rollback undoes. Look for it across every page (`listDeployments.all`): one
|
|
3507
|
+
* live preview per open pull request can push it off the first.
|
|
3430
3508
|
*/
|
|
3431
3509
|
function servesPrimary(deployment) {
|
|
3432
3510
|
return deployment.targets.some((target) => target.expires_at == null);
|
|
@@ -3461,8 +3539,1148 @@ async function rollbackDeployment(client, projectId, deploymentId, options) {
|
|
|
3461
3539
|
return client.rollbackDeployment(deploymentId, { project_id: projectId }, Object.keys(body).length === 0 ? void 0 : body);
|
|
3462
3540
|
}
|
|
3463
3541
|
//#endregion
|
|
3542
|
+
//#region src/lib/git/errors.ts
|
|
3543
|
+
/**
|
|
3544
|
+
* A git operation that failed, with a message ready to show the developer.
|
|
3545
|
+
*
|
|
3546
|
+
* What the module throws when git fails. The message is git's own (all of
|
|
3547
|
+
* its stderr, which often says how to fix the failure), or one of the
|
|
3548
|
+
* module's when git said nothing useful: output over 16 MB, a call that timed
|
|
3549
|
+
* out. The temporary staging area's file-system calls can still fail with
|
|
3550
|
+
* Node's own errors. Whether a failure is fatal is the caller's choice.
|
|
3551
|
+
*/
|
|
3552
|
+
var RepositoryError = class extends Error {
|
|
3553
|
+
/** Creates the error from its message. */
|
|
3554
|
+
constructor(message) {
|
|
3555
|
+
super(message);
|
|
3556
|
+
this.name = "RepositoryError";
|
|
3557
|
+
}
|
|
3558
|
+
/**
|
|
3559
|
+
* The one line of the message that says why git failed, for a one-line warning; never empty.
|
|
3560
|
+
*
|
|
3561
|
+
* git writes `warning:` and `hint:` lines before and after the line that
|
|
3562
|
+
* gives the reason, so the first `fatal:` or `error:` line is taken, then
|
|
3563
|
+
* the first line, then a fixed phrase when git said nothing.
|
|
3564
|
+
*/
|
|
3565
|
+
headline() {
|
|
3566
|
+
const lines = this.message.split("\n").map((line) => line.trim()).filter((line) => line !== "");
|
|
3567
|
+
const reason = lines.find((line) => line.startsWith("fatal:") || line.startsWith("error:"));
|
|
3568
|
+
if (reason !== void 0) return reason;
|
|
3569
|
+
return lines[0] ?? "git gave no reason";
|
|
3570
|
+
}
|
|
3571
|
+
};
|
|
3572
|
+
/**
|
|
3573
|
+
* git could not be started: there is none on `PATH`, or the directory to run it in does not exist.
|
|
3574
|
+
*
|
|
3575
|
+
* Its own type because callers treat it differently from git failing:
|
|
3576
|
+
* {@link Repository.open} answers "no repository" for it, where any other
|
|
3577
|
+
* failure is thrown.
|
|
3578
|
+
*/
|
|
3579
|
+
var GitDidNotStartError = class extends RepositoryError {
|
|
3580
|
+
/** Creates the error from the message Node gave. */
|
|
3581
|
+
constructor(message) {
|
|
3582
|
+
super(message);
|
|
3583
|
+
this.name = "GitDidNotStartError";
|
|
3584
|
+
}
|
|
3585
|
+
};
|
|
3586
|
+
//#endregion
|
|
3587
|
+
//#region src/lib/git/git.ts
|
|
3588
|
+
/**
|
|
3589
|
+
* What one git call returned, and ways to read it.
|
|
3590
|
+
*
|
|
3591
|
+
* A call returns one only when it succeeded or exited with a code the caller
|
|
3592
|
+
* allowed; anything else is thrown by {@link Git.run}. Reading lives here so
|
|
3593
|
+
* every caller reads git's output the same way: `paths()` for `-z` lists,
|
|
3594
|
+
* `text()` for one value, `reason()` for why it failed.
|
|
3595
|
+
*/
|
|
3596
|
+
var GitResult = class {
|
|
3597
|
+
exitCode;
|
|
3598
|
+
stdout;
|
|
3599
|
+
stderr;
|
|
3600
|
+
/** Wraps what one call returned. */
|
|
3601
|
+
constructor(exitCode, stdout, stderr) {
|
|
3602
|
+
this.exitCode = exitCode;
|
|
3603
|
+
this.stdout = stdout;
|
|
3604
|
+
this.stderr = stderr;
|
|
3605
|
+
}
|
|
3606
|
+
/** Whether git exited 0. */
|
|
3607
|
+
succeeded() {
|
|
3608
|
+
return this.exitCode === 0;
|
|
3609
|
+
}
|
|
3610
|
+
/** What git printed, without its final line break. */
|
|
3611
|
+
text() {
|
|
3612
|
+
return this.stdout.replace(/\n$/, "");
|
|
3613
|
+
}
|
|
3614
|
+
/** The NUL-separated paths git printed, for a `-z` command. */
|
|
3615
|
+
paths() {
|
|
3616
|
+
return this.stdout.split("\0").filter((path) => path !== "");
|
|
3617
|
+
}
|
|
3618
|
+
/**
|
|
3619
|
+
* Everything git wrote to stderr, its own reason for failing; empty when it wrote none.
|
|
3620
|
+
*
|
|
3621
|
+
* All of it, not the first line: git's later lines often say how to fix the
|
|
3622
|
+
* failure, as a refusal over ownership names the `safe.directory` command.
|
|
3623
|
+
*/
|
|
3624
|
+
reason() {
|
|
3625
|
+
return this.stderr.trim();
|
|
3626
|
+
}
|
|
3627
|
+
};
|
|
3628
|
+
/**
|
|
3629
|
+
* The git command line, run in one directory with one environment.
|
|
3630
|
+
*
|
|
3631
|
+
* The one place the CLI starts git. Every call goes through {@link run},
|
|
3632
|
+
* which makes it safe the same way (see there), so no caller can forget a
|
|
3633
|
+
* setting. It knows nothing about repositories or Zitadel: it runs git and
|
|
3634
|
+
* reads its answer. `with` gives the same git with more environment, which is
|
|
3635
|
+
* how a temporary staging area, a fallback author or an English locale is
|
|
3636
|
+
* applied to one call.
|
|
3637
|
+
*/
|
|
3638
|
+
var Git = class Git {
|
|
3639
|
+
/** The directory git runs in. */
|
|
3640
|
+
dir;
|
|
3641
|
+
env;
|
|
3642
|
+
/** Runs git in `dir`, with `env` added to its environment. */
|
|
3643
|
+
constructor(dir, env = {}) {
|
|
3644
|
+
this.dir = dir;
|
|
3645
|
+
this.env = env;
|
|
3646
|
+
}
|
|
3647
|
+
/** The same git, with `env` added to its environment. */
|
|
3648
|
+
with(env) {
|
|
3649
|
+
return new Git(this.dir, {
|
|
3650
|
+
...this.env,
|
|
3651
|
+
...env
|
|
3652
|
+
});
|
|
3653
|
+
}
|
|
3654
|
+
/**
|
|
3655
|
+
* Runs git with `args`; throws a {@link RepositoryError} for an exit code other than 0 or `allow`.
|
|
3656
|
+
*
|
|
3657
|
+
* Every call is made safe the same way:
|
|
3658
|
+
* - `GIT_LITERAL_PATHSPECS`, `GIT_GLOB_PATHSPECS`, `GIT_NOGLOB_PATHSPECS`
|
|
3659
|
+
* and `GIT_ICASE_PATHSPECS` are dropped from the inherited environment.
|
|
3660
|
+
* They change what every path given to git matches, and the literal one
|
|
3661
|
+
* turns off the `:(exclude)` a commit uses to keep a secret out.
|
|
3662
|
+
* - `GIT_TERMINAL_PROMPT=0`, so git never waits on a prompt.
|
|
3663
|
+
* - Output is capped at 16 MB and a call at five minutes, so a stuck or
|
|
3664
|
+
* runaway git cannot hang the CLI.
|
|
3665
|
+
*
|
|
3666
|
+
* `GIT_DIR`, `GIT_WORK_TREE` and `GIT_INDEX_FILE` are honoured, as git
|
|
3667
|
+
* itself does: a build that keeps `.git` elsewhere names it this way. No
|
|
3668
|
+
* flag is set that git passes on to hooks, so a hook that runs during a
|
|
3669
|
+
* commit sees the developer's own settings: a path is made literal where it
|
|
3670
|
+
* is given ({@link Git.literal}), and the locale is set to English only for
|
|
3671
|
+
* the one call whose wording is read.
|
|
3672
|
+
*
|
|
3673
|
+
* Output is read as UTF-8 unless `encoding` says otherwise; `latin1` keeps
|
|
3674
|
+
* every byte as one character, for a path list that must go back to git
|
|
3675
|
+
* unchanged.
|
|
3676
|
+
*
|
|
3677
|
+
* An allowed exit code is an answer, not a failure. Only a git that ran and
|
|
3678
|
+
* exited has one, so no allow list can mistake a missing git for an answer;
|
|
3679
|
+
* that throws {@link GitDidNotStartError}. Every other failure carries git's
|
|
3680
|
+
* own message, or Node's when git wrote nothing.
|
|
3681
|
+
*/
|
|
3682
|
+
run(args, allow = [], encoding = "utf8") {
|
|
3683
|
+
const inherited = { ...process.env };
|
|
3684
|
+
for (const name of [
|
|
3685
|
+
"GIT_LITERAL_PATHSPECS",
|
|
3686
|
+
"GIT_GLOB_PATHSPECS",
|
|
3687
|
+
"GIT_NOGLOB_PATHSPECS",
|
|
3688
|
+
"GIT_ICASE_PATHSPECS"
|
|
3689
|
+
]) delete inherited[name];
|
|
3690
|
+
const env = {
|
|
3691
|
+
...inherited,
|
|
3692
|
+
GIT_TERMINAL_PROMPT: "0",
|
|
3693
|
+
...this.env
|
|
3694
|
+
};
|
|
3695
|
+
return new Promise((resolveResult, reject) => {
|
|
3696
|
+
execFile("git", [...args], {
|
|
3697
|
+
cwd: this.dir,
|
|
3698
|
+
env,
|
|
3699
|
+
encoding,
|
|
3700
|
+
maxBuffer: 16777216,
|
|
3701
|
+
timeout: 3e5
|
|
3702
|
+
}, (error, stdout, stderr) => {
|
|
3703
|
+
const failure = error;
|
|
3704
|
+
if (failure === null) {
|
|
3705
|
+
resolveResult(new GitResult(0, stdout, stderr));
|
|
3706
|
+
return;
|
|
3707
|
+
}
|
|
3708
|
+
if (typeof failure.code === "number") {
|
|
3709
|
+
const result = new GitResult(failure.code, stdout, stderr);
|
|
3710
|
+
if (allow.includes(failure.code)) resolveResult(result);
|
|
3711
|
+
else reject(new RepositoryError(result.reason() || failure.message));
|
|
3712
|
+
} else if (failure.code === "ENOENT") reject(new GitDidNotStartError(failure.message));
|
|
3713
|
+
else if (failure.code === "ERR_CHILD_PROCESS_STDIO_MAXBUFFER") reject(new RepositoryError("git printed more than 16 MB"));
|
|
3714
|
+
else if (failure.killed) reject(new RepositoryError("git did not finish in time"));
|
|
3715
|
+
else reject(new RepositoryError(stderr.trim() || failure.message));
|
|
3716
|
+
});
|
|
3717
|
+
});
|
|
3718
|
+
}
|
|
3719
|
+
/**
|
|
3720
|
+
* Every file staged under the directory, relative to it.
|
|
3721
|
+
*
|
|
3722
|
+
* `--relative` both limits the list to the directory and names each path
|
|
3723
|
+
* from it, as a path filter expects. `--no-renames` lists both ends of a
|
|
3724
|
+
* rename, so neither escapes a check.
|
|
3725
|
+
*/
|
|
3726
|
+
async stagedFiles() {
|
|
3727
|
+
return (await this.run([
|
|
3728
|
+
"diff",
|
|
3729
|
+
"--cached",
|
|
3730
|
+
"-z",
|
|
3731
|
+
"--name-only",
|
|
3732
|
+
"--no-renames",
|
|
3733
|
+
"--relative"
|
|
3734
|
+
])).paths();
|
|
3735
|
+
}
|
|
3736
|
+
/**
|
|
3737
|
+
* `path` as a pathspec that matches only itself, so `app/[id].tsx` does not also match `app/i.tsx`.
|
|
3738
|
+
*
|
|
3739
|
+
* Set per path rather than with `--literal-pathspecs`, which git would pass
|
|
3740
|
+
* on to every hook it runs as `GIT_LITERAL_PATHSPECS`.
|
|
3741
|
+
*/
|
|
3742
|
+
static literal(path) {
|
|
3743
|
+
return `:(literal)${path}`;
|
|
3744
|
+
}
|
|
3745
|
+
/** Whether `path`, relative to the directory, names something that exists. */
|
|
3746
|
+
exists(path) {
|
|
3747
|
+
return path !== "" && existsSync(resolve(this.dir, path));
|
|
3748
|
+
}
|
|
3749
|
+
};
|
|
3750
|
+
//#endregion
|
|
3751
|
+
//#region src/lib/git/author-identity.ts
|
|
3752
|
+
/**
|
|
3753
|
+
* Where the author's name on a commit would come from.
|
|
3754
|
+
*
|
|
3755
|
+
* Three cases: the developer has an identity of their own (config or
|
|
3756
|
+
* `GIT_AUTHOR_*`), git can only guess one from the username and hostname, or
|
|
3757
|
+
* git cannot build one at all, as on a bare CI runner. Only the last needs an
|
|
3758
|
+
* author supplied, since an invented author is worse than git's own guess;
|
|
3759
|
+
* the caller is told which it was, so it can say so. Applying an author to a
|
|
3760
|
+
* commit is {@link StagingArea.commit}'s job.
|
|
3761
|
+
*/
|
|
3762
|
+
var AuthorIdentity = class AuthorIdentity {
|
|
3763
|
+
/** Where the author's name comes from. */
|
|
3764
|
+
source;
|
|
3765
|
+
/** The identity from `source`. */
|
|
3766
|
+
constructor(source) {
|
|
3767
|
+
this.source = source;
|
|
3768
|
+
}
|
|
3769
|
+
/**
|
|
3770
|
+
* Where the author git would commit with in `git`'s directory comes from.
|
|
3771
|
+
*
|
|
3772
|
+
* Without an identity of the developer's own, git guesses one from the
|
|
3773
|
+
* username and hostname and commits anyway; `user.useConfigOnly` turns that
|
|
3774
|
+
* guess back into an error, which tells a real identity from a guessed one.
|
|
3775
|
+
*/
|
|
3776
|
+
static async of(git) {
|
|
3777
|
+
if ((await git.run([
|
|
3778
|
+
"-c",
|
|
3779
|
+
"user.useConfigOnly=true",
|
|
3780
|
+
"var",
|
|
3781
|
+
"GIT_AUTHOR_IDENT"
|
|
3782
|
+
], [128])).succeeded()) return new AuthorIdentity("yours");
|
|
3783
|
+
if ((await git.run(["var", "GIT_AUTHOR_IDENT"], [128])).succeeded()) return new AuthorIdentity("guessed");
|
|
3784
|
+
return new AuthorIdentity("fallback");
|
|
3785
|
+
}
|
|
3786
|
+
};
|
|
3787
|
+
//#endregion
|
|
3788
|
+
//#region src/lib/git/checks/head-is-on-a-branch.ts
|
|
3789
|
+
/**
|
|
3790
|
+
* HEAD is a branch, so a commit is not lost on a detached HEAD.
|
|
3791
|
+
*
|
|
3792
|
+
* A commit on a detached HEAD belongs to no branch, and the developer's next
|
|
3793
|
+
* checkout leaves it behind. A branch with no commit yet, as in a repository
|
|
3794
|
+
* just created, counts as a branch.
|
|
3795
|
+
*/
|
|
3796
|
+
var HeadIsOnABranch = class {
|
|
3797
|
+
/** Why a commit here would be lost, or null. */
|
|
3798
|
+
async problem(git) {
|
|
3799
|
+
if ((await git.run([
|
|
3800
|
+
"symbolic-ref",
|
|
3801
|
+
"--quiet",
|
|
3802
|
+
"HEAD"
|
|
3803
|
+
], [1])).succeeded()) return null;
|
|
3804
|
+
return "HEAD is detached, so the commit would be on no branch";
|
|
3805
|
+
}
|
|
3806
|
+
};
|
|
3807
|
+
//#endregion
|
|
3808
|
+
//#region src/lib/git/checks/no-operation-under-way.ts
|
|
3809
|
+
/**
|
|
3810
|
+
* No merge, rebase, cherry-pick, revert or bisect is under way, so a commit lands where meant.
|
|
3811
|
+
*
|
|
3812
|
+
* Mid-merge, a commit would conclude the merge with the developer's
|
|
3813
|
+
* half-resolved work; mid-rebase or cherry-pick, it would be woven into a
|
|
3814
|
+
* history being rewritten; mid-bisect, it would land on whichever commit is
|
|
3815
|
+
* being tested.
|
|
3816
|
+
*/
|
|
3817
|
+
var NoOperationUnderWay = class {
|
|
3818
|
+
/**
|
|
3819
|
+
* "a merge is in progress" and the like, or null.
|
|
3820
|
+
*
|
|
3821
|
+
* Each operation keeps a marker file in the git directory while it is under
|
|
3822
|
+
* way. `--git-path` finds each one for this worktree, which in a linked
|
|
3823
|
+
* worktree is not under `.git/`.
|
|
3824
|
+
*/
|
|
3825
|
+
async problem(git) {
|
|
3826
|
+
const operations = [
|
|
3827
|
+
{
|
|
3828
|
+
marker: "MERGE_HEAD",
|
|
3829
|
+
name: "a merge"
|
|
3830
|
+
},
|
|
3831
|
+
{
|
|
3832
|
+
marker: "rebase-merge",
|
|
3833
|
+
name: "a rebase"
|
|
3834
|
+
},
|
|
3835
|
+
{
|
|
3836
|
+
marker: "rebase-apply",
|
|
3837
|
+
name: "a rebase"
|
|
3838
|
+
},
|
|
3839
|
+
{
|
|
3840
|
+
marker: "CHERRY_PICK_HEAD",
|
|
3841
|
+
name: "a cherry-pick"
|
|
3842
|
+
},
|
|
3843
|
+
{
|
|
3844
|
+
marker: "REVERT_HEAD",
|
|
3845
|
+
name: "a revert"
|
|
3846
|
+
},
|
|
3847
|
+
{
|
|
3848
|
+
marker: "BISECT_LOG",
|
|
3849
|
+
name: "a bisect"
|
|
3850
|
+
}
|
|
3851
|
+
];
|
|
3852
|
+
const args = operations.flatMap((operation) => ["--git-path", operation.marker]);
|
|
3853
|
+
const paths = (await git.run(["rev-parse", ...args])).text().split("\n");
|
|
3854
|
+
const found = operations.find((_, index) => git.exists(paths[index] ?? ""));
|
|
3855
|
+
if (found === void 0) return null;
|
|
3856
|
+
return `${found.name} is in progress`;
|
|
3857
|
+
}
|
|
3858
|
+
};
|
|
3859
|
+
//#endregion
|
|
3860
|
+
//#region src/lib/git/checks/nothing-excluded-is-staged.ts
|
|
3861
|
+
/**
|
|
3862
|
+
* No staged file is one `except` matches.
|
|
3863
|
+
*
|
|
3864
|
+
* The last guard before a commit. {@link StagingArea.addAll} already leaves
|
|
3865
|
+
* excluded files out, but a file committed once cannot be taken out of
|
|
3866
|
+
* history, so what is about to be committed is checked rather than trusted.
|
|
3867
|
+
*/
|
|
3868
|
+
var NothingExcludedIsStaged = class {
|
|
3869
|
+
except;
|
|
3870
|
+
/** Checks against `except`. */
|
|
3871
|
+
constructor(except) {
|
|
3872
|
+
this.except = except;
|
|
3873
|
+
}
|
|
3874
|
+
/** Which excluded file is still staged, or null. */
|
|
3875
|
+
async problem(git) {
|
|
3876
|
+
const leaked = (await git.stagedFiles()).find(this.except);
|
|
3877
|
+
if (leaked === void 0) return null;
|
|
3878
|
+
return `${leaked} could not be kept out of the commit, so nothing was committed`;
|
|
3879
|
+
}
|
|
3880
|
+
};
|
|
3881
|
+
//#endregion
|
|
3882
|
+
//#region src/lib/git/checks/something-is-staged.ts
|
|
3883
|
+
/**
|
|
3884
|
+
* Something is staged, so a commit has something to record.
|
|
3885
|
+
*
|
|
3886
|
+
* When everything was excluded, or nothing changed, the answer is "there was
|
|
3887
|
+
* nothing to commit" rather than an empty commit or git's own refusal.
|
|
3888
|
+
*/
|
|
3889
|
+
var SomethingIsStaged = class {
|
|
3890
|
+
/** That nothing is staged, or null. */
|
|
3891
|
+
async problem(git) {
|
|
3892
|
+
if ((await git.stagedFiles()).length > 0) return null;
|
|
3893
|
+
return "there was nothing to commit";
|
|
3894
|
+
}
|
|
3895
|
+
};
|
|
3896
|
+
//#endregion
|
|
3897
|
+
//#region src/lib/git/operations/current-commit.ts
|
|
3898
|
+
/**
|
|
3899
|
+
* The commit the repository is at, or null before its first commit.
|
|
3900
|
+
*
|
|
3901
|
+
* A repository with no commit yet is an answer, null, not a failure.
|
|
3902
|
+
*/
|
|
3903
|
+
var CurrentCommit = class {
|
|
3904
|
+
/** Nothing to check. */
|
|
3905
|
+
checks() {
|
|
3906
|
+
return [];
|
|
3907
|
+
}
|
|
3908
|
+
/** Never refused. */
|
|
3909
|
+
refused() {
|
|
3910
|
+
return null;
|
|
3911
|
+
}
|
|
3912
|
+
/** Asks git for HEAD's commit. */
|
|
3913
|
+
async run(git) {
|
|
3914
|
+
const result = await git.run([
|
|
3915
|
+
"rev-parse",
|
|
3916
|
+
"--verify",
|
|
3917
|
+
"--quiet",
|
|
3918
|
+
"HEAD^{commit}"
|
|
3919
|
+
], [1]);
|
|
3920
|
+
if (!result.succeeded()) return null;
|
|
3921
|
+
return result.text();
|
|
3922
|
+
}
|
|
3923
|
+
};
|
|
3924
|
+
//#endregion
|
|
3925
|
+
//#region src/lib/git/staging-area.ts
|
|
3926
|
+
/**
|
|
3927
|
+
* A temporary staging area (`GIT_INDEX_FILE`), so a commit or a diff is put
|
|
3928
|
+
* together without touching the developer's own, which may hold staged work
|
|
3929
|
+
* of theirs, another package's included.
|
|
3930
|
+
*
|
|
3931
|
+
* Made with {@link temporary}, which removes it however the work ends. A
|
|
3932
|
+
* commit starts it at the last commit ({@link startFromHead}); a diff starts
|
|
3933
|
+
* it from a copy of the developer's ({@link startFromDevelopers}). Its methods
|
|
3934
|
+
* are the steps an operation takes, so the operation reads as a list of them.
|
|
3935
|
+
*/
|
|
3936
|
+
var StagingArea = class StagingArea {
|
|
3937
|
+
/** git, pointed at this staging area. */
|
|
3938
|
+
git;
|
|
3939
|
+
index;
|
|
3940
|
+
/** The staging area kept in `index`. */
|
|
3941
|
+
constructor(git, index) {
|
|
3942
|
+
this.index = index;
|
|
3943
|
+
this.git = git.with({ GIT_INDEX_FILE: index });
|
|
3944
|
+
}
|
|
3945
|
+
/**
|
|
3946
|
+
* Runs `work` with a new staging area for `git`'s directory, removed afterwards.
|
|
3947
|
+
*
|
|
3948
|
+
* A failure to remove it is dropped: by then the work is done, and a commit
|
|
3949
|
+
* that landed must not be reported as failed because a temporary file was
|
|
3950
|
+
* briefly held open (as Windows Defender does).
|
|
3951
|
+
*/
|
|
3952
|
+
static async temporary(git, work) {
|
|
3953
|
+
const scratch = await mkdtemp(join(tmpdir(), "git-staging-"));
|
|
3954
|
+
try {
|
|
3955
|
+
return await work(new StagingArea(git, join(scratch, "index")));
|
|
3956
|
+
} finally {
|
|
3957
|
+
await rm(scratch, {
|
|
3958
|
+
recursive: true,
|
|
3959
|
+
force: true
|
|
3960
|
+
}).catch(() => void 0);
|
|
3961
|
+
}
|
|
3962
|
+
}
|
|
3963
|
+
/**
|
|
3964
|
+
* Starts at the last commit, keeping what the developer's staging area knows about each file; empty before one.
|
|
3965
|
+
*
|
|
3966
|
+
* It starts as a copy of `developers`' staging area and is then reset to
|
|
3967
|
+
* the last commit with `read-tree -m`, which keeps each entry's flags and
|
|
3968
|
+
* file timestamps where the content is unchanged. The flags matter: a file
|
|
3969
|
+
* the developer marked `skip-worktree` or `assume-unchanged` (a local
|
|
3970
|
+
* config with real credentials, or the files outside a sparse checkout)
|
|
3971
|
+
* keeps its mark, so its local state is never staged. The timestamps spare
|
|
3972
|
+
* git from hashing every file again. Another package's staged work is
|
|
3973
|
+
* dropped by the reset, so the commit carries the rest of the repository as
|
|
3974
|
+
* last committed.
|
|
3975
|
+
*/
|
|
3976
|
+
async startFromHead(developers) {
|
|
3977
|
+
await this.startFromDevelopers(developers);
|
|
3978
|
+
const head = await new CurrentCommit().run(this.git);
|
|
3979
|
+
if (head === null) await this.git.run(["read-tree", "--empty"]);
|
|
3980
|
+
else await this.git.run([
|
|
3981
|
+
"read-tree",
|
|
3982
|
+
"-m",
|
|
3983
|
+
head
|
|
3984
|
+
]);
|
|
3985
|
+
}
|
|
3986
|
+
/**
|
|
3987
|
+
* Starts as a copy of the developer's staging area, keeping its timestamps.
|
|
3988
|
+
*
|
|
3989
|
+
* A copy rather than an empty one, so a tracked file that `.gitignore` now
|
|
3990
|
+
* matches still diffs. The timestamps matter: git trusts an entry only for a
|
|
3991
|
+
* file older than the index, so a copy stamped now hid an edit made in the
|
|
3992
|
+
* same second as the developer's last `git add`. A fresh repository has no
|
|
3993
|
+
* index yet, and the copy then starts empty.
|
|
3994
|
+
*/
|
|
3995
|
+
async startFromDevelopers(developers) {
|
|
3996
|
+
const index = resolve(developers.dir, (await developers.run([
|
|
3997
|
+
"rev-parse",
|
|
3998
|
+
"--git-path",
|
|
3999
|
+
"index"
|
|
4000
|
+
])).text());
|
|
4001
|
+
if (existsSync(index)) {
|
|
4002
|
+
await copyFile(index, this.index);
|
|
4003
|
+
const { atime, mtime } = await stat(index);
|
|
4004
|
+
await utimes(this.index, atime, mtime);
|
|
4005
|
+
}
|
|
4006
|
+
}
|
|
4007
|
+
/**
|
|
4008
|
+
* Stages everything under the directory but what `except` matches, as `git add --all -- .` would.
|
|
4009
|
+
*
|
|
4010
|
+
* The changed and untracked files are listed first to find the ones
|
|
4011
|
+
* `except` matches, and those are then left out of a single
|
|
4012
|
+
* `git add --all -- .` with `:(exclude)`. An excluded file is never read, so
|
|
4013
|
+
* its content is never written to the object store, where it would sit as
|
|
4014
|
+
* a loose object until git cleans it up. Staging stays `git add --all -- .`
|
|
4015
|
+
* in every other way: a file that vanishes meanwhile, a tracked file in an
|
|
4016
|
+
* ignored directory and a sparse checkout all behave as they do for the
|
|
4017
|
+
* developer, and matching stays linear however many files there are. A
|
|
4018
|
+
* file that is already tracked and excluded keeps what the last commit has.
|
|
4019
|
+
*
|
|
4020
|
+
* The pathspecs go through a file beside the staging area, read and written
|
|
4021
|
+
* byte for byte: a name that is not valid UTF-8, which Linux allows, would
|
|
4022
|
+
* otherwise come back changed and match nothing. `except` sees each name as
|
|
4023
|
+
* UTF-8.
|
|
4024
|
+
*
|
|
4025
|
+
* Exit 1 is accepted: git gives it when everything `.` matches is a file the
|
|
4026
|
+
* developer marked `skip-worktree`, having staged nothing, which is the
|
|
4027
|
+
* right result. A real failure, such as a file that cannot be read, exits
|
|
4028
|
+
* 128. Whether anything was staged is for the caller to check.
|
|
4029
|
+
*/
|
|
4030
|
+
async addAll(except) {
|
|
4031
|
+
const excluded = (await this.git.run([
|
|
4032
|
+
"ls-files",
|
|
4033
|
+
"-z",
|
|
4034
|
+
"--modified",
|
|
4035
|
+
"--deleted",
|
|
4036
|
+
"--others",
|
|
4037
|
+
"--exclude-standard",
|
|
4038
|
+
"--",
|
|
4039
|
+
"."
|
|
4040
|
+
], [], "latin1")).paths().filter((path) => except(Buffer.from(path, "latin1").toString("utf8"))).map((path) => `:(exclude,literal)${path}`);
|
|
4041
|
+
const list = join(dirname(this.index), "paths");
|
|
4042
|
+
await writeFile(list, Buffer.from([".", ...excluded].join("\0"), "latin1"));
|
|
4043
|
+
await this.git.run([
|
|
4044
|
+
"add",
|
|
4045
|
+
"--all",
|
|
4046
|
+
`--pathspec-from-file=${list}`,
|
|
4047
|
+
"--pathspec-file-nul"
|
|
4048
|
+
], [1]);
|
|
4049
|
+
}
|
|
4050
|
+
/**
|
|
4051
|
+
* Marks the untracked files among `paths` "intent to add", so a diff shows them as added.
|
|
4052
|
+
*
|
|
4053
|
+
* Intent to add records the file without its content, so nothing is written
|
|
4054
|
+
* to the object store. Only paths that exist are passed: `git add` refuses
|
|
4055
|
+
* the whole call when one matches nothing. An ignored path makes it mark the
|
|
4056
|
+
* rest and exit 1, which is accepted. Each path is taken literally
|
|
4057
|
+
* ({@link Git.literal}), so `app/[id].tsx` marks only itself.
|
|
4058
|
+
*/
|
|
4059
|
+
async markAsAdded(paths) {
|
|
4060
|
+
const present = paths.filter((path) => this.git.exists(path)).map(Git.literal);
|
|
4061
|
+
if (present.length > 0) await this.git.run([
|
|
4062
|
+
"add",
|
|
4063
|
+
"--intent-to-add",
|
|
4064
|
+
"--",
|
|
4065
|
+
...present
|
|
4066
|
+
], [1]);
|
|
4067
|
+
}
|
|
4068
|
+
/** The first problem any of `checks` finds with what is staged, or null. */
|
|
4069
|
+
async problem(checks) {
|
|
4070
|
+
for (const check of checks) {
|
|
4071
|
+
const problem = await check.problem(this.git);
|
|
4072
|
+
if (problem !== null) return problem;
|
|
4073
|
+
}
|
|
4074
|
+
return null;
|
|
4075
|
+
}
|
|
4076
|
+
/**
|
|
4077
|
+
* Commits what is staged with `message`, naming `author` as author and committer when given.
|
|
4078
|
+
*
|
|
4079
|
+
* Without `author`, git names whoever it would. An author is set through
|
|
4080
|
+
* the environment rather than `-c user.*`: an inherited empty
|
|
4081
|
+
* `GIT_AUTHOR_NAME=`, as some CI runners leave it, outranks `-c`, and git
|
|
4082
|
+
* would refuse the commit with "empty ident name".
|
|
4083
|
+
*
|
|
4084
|
+
* Signing is turned off, so a configured key cannot block on a passphrase
|
|
4085
|
+
* prompt. `--no-verify` skips the pre-commit and commit-msg hooks, which a
|
|
4086
|
+
* scaffolded app's linter would otherwise fail; post-commit hooks still run,
|
|
4087
|
+
* as they always did.
|
|
4088
|
+
*/
|
|
4089
|
+
async commit(message, author) {
|
|
4090
|
+
const commit = [
|
|
4091
|
+
"-c",
|
|
4092
|
+
"commit.gpgsign=false",
|
|
4093
|
+
"commit",
|
|
4094
|
+
"--quiet",
|
|
4095
|
+
"--no-verify",
|
|
4096
|
+
`--message=${message}`
|
|
4097
|
+
];
|
|
4098
|
+
if (author === null) await this.git.run(commit);
|
|
4099
|
+
else await this.git.with({
|
|
4100
|
+
GIT_AUTHOR_NAME: author.name,
|
|
4101
|
+
GIT_AUTHOR_EMAIL: author.email,
|
|
4102
|
+
GIT_COMMITTER_NAME: author.name,
|
|
4103
|
+
GIT_COMMITTER_EMAIL: author.email
|
|
4104
|
+
}).run(commit);
|
|
4105
|
+
}
|
|
4106
|
+
};
|
|
4107
|
+
//#endregion
|
|
4108
|
+
//#region src/lib/git/operations/commit-all.ts
|
|
4109
|
+
/**
|
|
4110
|
+
* Commits everything under the directory but `options.except`.
|
|
4111
|
+
*
|
|
4112
|
+
* The commit is built in a temporary staging area that starts from the last
|
|
4113
|
+
* commit, never in the developer's own: theirs may hold another package's
|
|
4114
|
+
* staged work, which would be swept in. It carries the developer's per-file
|
|
4115
|
+
* flags, so a file they told git to leave alone stays out of the commit. A pathspec is no way round that,
|
|
4116
|
+
* since `git commit -- .` commits the working tree and ignores the staging
|
|
4117
|
+
* area, so an excluded file that is already tracked would go in anyway.
|
|
4118
|
+
*
|
|
4119
|
+
* Expects nothing uncommitted of the developer's under the directory: once
|
|
4120
|
+
* committed, their staging area there is reset to the new commit, or they
|
|
4121
|
+
* would see the commit's files staged for deletion.
|
|
4122
|
+
*/
|
|
4123
|
+
var CommitAll = class {
|
|
4124
|
+
message;
|
|
4125
|
+
options;
|
|
4126
|
+
/** Commits with `message`, as `options` say. */
|
|
4127
|
+
constructor(message, options) {
|
|
4128
|
+
this.message = message;
|
|
4129
|
+
this.options = options;
|
|
4130
|
+
}
|
|
4131
|
+
/**
|
|
4132
|
+
* Nothing is under way that the commit would land in the middle of.
|
|
4133
|
+
*
|
|
4134
|
+
* Mid-merge, a commit would record a merge with the wrong parents; mid-rebase
|
|
4135
|
+
* or on a detached HEAD, it would sit on no branch and be lost.
|
|
4136
|
+
*/
|
|
4137
|
+
checks() {
|
|
4138
|
+
return [new NoOperationUnderWay(), new HeadIsOnABranch()];
|
|
4139
|
+
}
|
|
4140
|
+
/** Nothing committed, for `problem`. */
|
|
4141
|
+
refused(problem) {
|
|
4142
|
+
return {
|
|
4143
|
+
committed: false,
|
|
4144
|
+
reason: problem
|
|
4145
|
+
};
|
|
4146
|
+
}
|
|
4147
|
+
/**
|
|
4148
|
+
* Stages everything but the excluded files, checks, and commits.
|
|
4149
|
+
*
|
|
4150
|
+
* The caller's fallback author is named only where git cannot build an
|
|
4151
|
+
* identity at all; a commit with no author would leave nothing to diff a
|
|
4152
|
+
* later deploy against.
|
|
4153
|
+
*
|
|
4154
|
+
* What is about to be committed is checked rather than trusted, since a
|
|
4155
|
+
* file committed once cannot be taken out of history. The reset of the
|
|
4156
|
+
* developer's staging area afterwards is best effort, since the commit has
|
|
4157
|
+
* already landed.
|
|
4158
|
+
*
|
|
4159
|
+
* A commit can land and git still fail afterwards: a post-commit hook that
|
|
4160
|
+
* errors, or one that runs past the five-minute limit. So when the commit
|
|
4161
|
+
* fails, HEAD is read again, and a HEAD that moved means the commit was made
|
|
4162
|
+
* and is reported as made.
|
|
4163
|
+
*/
|
|
4164
|
+
async run(git) {
|
|
4165
|
+
const identity = await AuthorIdentity.of(git);
|
|
4166
|
+
const before = await new CurrentCommit().run(git);
|
|
4167
|
+
return StagingArea.temporary(git, async (staging) => {
|
|
4168
|
+
await staging.startFromHead(git);
|
|
4169
|
+
await staging.addAll(this.options.except);
|
|
4170
|
+
const problem = await staging.problem([new NothingExcludedIsStaged(this.options.except), new SomethingIsStaged()]);
|
|
4171
|
+
if (problem !== null) return this.refused(problem);
|
|
4172
|
+
try {
|
|
4173
|
+
if (identity.source === "fallback") await staging.commit(this.message, this.options.authorIfGitHasNone);
|
|
4174
|
+
else await staging.commit(this.message, null);
|
|
4175
|
+
} catch (error) {
|
|
4176
|
+
if (await new CurrentCommit().run(git) === before) throw error;
|
|
4177
|
+
}
|
|
4178
|
+
const sha = await new CurrentCommit().run(git);
|
|
4179
|
+
if (sha === null) throw new RepositoryError("the commit left no HEAD to record");
|
|
4180
|
+
await git.run([
|
|
4181
|
+
"reset",
|
|
4182
|
+
"--quiet",
|
|
4183
|
+
"--",
|
|
4184
|
+
"."
|
|
4185
|
+
]).catch(() => void 0);
|
|
4186
|
+
return {
|
|
4187
|
+
committed: true,
|
|
4188
|
+
sha,
|
|
4189
|
+
author: identity.source
|
|
4190
|
+
};
|
|
4191
|
+
});
|
|
4192
|
+
}
|
|
4193
|
+
};
|
|
4194
|
+
//#endregion
|
|
4195
|
+
//#region src/lib/git/operations/current-subject.ts
|
|
4196
|
+
/**
|
|
4197
|
+
* The subject line of the commit the repository is at, or null before its first commit.
|
|
4198
|
+
*
|
|
4199
|
+
* `--no-show-signature` overrides a developer's `log.showSignature`, which
|
|
4200
|
+
* would otherwise put gpg's lines ahead of the subject on a signed commit. A
|
|
4201
|
+
* repository with no commit yet makes `git log` exit 128, which is an answer,
|
|
4202
|
+
* null, not a failure.
|
|
4203
|
+
*/
|
|
4204
|
+
var CurrentSubject = class {
|
|
4205
|
+
/** Nothing to check. */
|
|
4206
|
+
checks() {
|
|
4207
|
+
return [];
|
|
4208
|
+
}
|
|
4209
|
+
/** Never refused. */
|
|
4210
|
+
refused() {
|
|
4211
|
+
return null;
|
|
4212
|
+
}
|
|
4213
|
+
/** Asks git for HEAD's subject. */
|
|
4214
|
+
async run(git) {
|
|
4215
|
+
const result = await git.run([
|
|
4216
|
+
"log",
|
|
4217
|
+
"-1",
|
|
4218
|
+
"--no-show-signature",
|
|
4219
|
+
"--format=%s"
|
|
4220
|
+
], [128]);
|
|
4221
|
+
if (!result.succeeded()) return null;
|
|
4222
|
+
return result.text() || null;
|
|
4223
|
+
}
|
|
4224
|
+
};
|
|
4225
|
+
//#endregion
|
|
4226
|
+
//#region src/lib/git/change.ts
|
|
4227
|
+
/**
|
|
4228
|
+
* One changed file, as `git diff --raw -z` lists it: where it is, what
|
|
4229
|
+
* happened to it, and where it came from when it was renamed or copied.
|
|
4230
|
+
*
|
|
4231
|
+
* Read from git's raw list rather than from the patch's `diff --git` headers:
|
|
4232
|
+
* the list is NUL-separated and never quoted, while a header quotes a path
|
|
4233
|
+
* holding a space or a non-ASCII byte and cannot be split reliably.
|
|
4234
|
+
* {@link DiffFilesSince} pairs each change with its part of the patch.
|
|
4235
|
+
*/
|
|
4236
|
+
var Change = class Change {
|
|
4237
|
+
path;
|
|
4238
|
+
status;
|
|
4239
|
+
/** Where a renamed or copied file came from. */
|
|
4240
|
+
previousPath;
|
|
4241
|
+
/** A change of `status` to `path`, from `previousPath` for a rename or a copy. */
|
|
4242
|
+
constructor(path, status, previousPath) {
|
|
4243
|
+
this.path = path;
|
|
4244
|
+
this.status = status;
|
|
4245
|
+
this.previousPath = previousPath;
|
|
4246
|
+
}
|
|
4247
|
+
/**
|
|
4248
|
+
* Every change in the NUL-separated `fields` of a `--raw -z` list, in its order.
|
|
4249
|
+
*
|
|
4250
|
+
* Each entry is `:<modes> <ids> <letter>[<score>]`, then the path, or for a
|
|
4251
|
+
* rename or a copy where it came from and where it went. The entries are
|
|
4252
|
+
* read in order rather than by looking for a `:`, since a path may start
|
|
4253
|
+
* with one. M, T (a type change) and U (unmerged) all count as modified.
|
|
4254
|
+
*/
|
|
4255
|
+
static listedIn(fields) {
|
|
4256
|
+
const statuses = {
|
|
4257
|
+
A: "added",
|
|
4258
|
+
D: "deleted",
|
|
4259
|
+
R: "renamed",
|
|
4260
|
+
C: "copied"
|
|
4261
|
+
};
|
|
4262
|
+
const unread = fields.values();
|
|
4263
|
+
const changes = [];
|
|
4264
|
+
for (const entry of unread) {
|
|
4265
|
+
const status = statuses[entry.charAt(entry.lastIndexOf(" ") + 1)] ?? "modified";
|
|
4266
|
+
const first = unread.next().value ?? "";
|
|
4267
|
+
if (status === "renamed" || status === "copied") changes.push(new Change(unread.next().value ?? "", status, first));
|
|
4268
|
+
else changes.push(new Change(first, status));
|
|
4269
|
+
}
|
|
4270
|
+
return changes;
|
|
4271
|
+
}
|
|
4272
|
+
/** This change, as a diff reports it, with `patch` as its part of the diff. */
|
|
4273
|
+
diffedWith(patch) {
|
|
4274
|
+
if (this.previousPath === void 0) return {
|
|
4275
|
+
path: this.path,
|
|
4276
|
+
status: this.status,
|
|
4277
|
+
patch
|
|
4278
|
+
};
|
|
4279
|
+
return {
|
|
4280
|
+
path: this.path,
|
|
4281
|
+
status: this.status,
|
|
4282
|
+
previous_path: this.previousPath,
|
|
4283
|
+
patch
|
|
4284
|
+
};
|
|
4285
|
+
}
|
|
4286
|
+
};
|
|
4287
|
+
//#endregion
|
|
4288
|
+
//#region src/lib/git/checks/commit-is-here.ts
|
|
4289
|
+
/**
|
|
4290
|
+
* `commit` is a commit in local history; a shallow clone may lack it.
|
|
4291
|
+
*
|
|
4292
|
+
* Checked before a diff, so a missing commit answers null ("no diff
|
|
4293
|
+
* available") rather than failing, and an empty diff is never mistaken for a
|
|
4294
|
+
* clean tree.
|
|
4295
|
+
*/
|
|
4296
|
+
var CommitIsHere = class {
|
|
4297
|
+
commit;
|
|
4298
|
+
/** Checks for `commit`. */
|
|
4299
|
+
constructor(commit) {
|
|
4300
|
+
this.commit = commit;
|
|
4301
|
+
}
|
|
4302
|
+
/**
|
|
4303
|
+
* Why the commit cannot be used, or null.
|
|
4304
|
+
*
|
|
4305
|
+
* Only an object id, full or abbreviated, is handed to git; anything else
|
|
4306
|
+
* could be read as an option or name a branch.
|
|
4307
|
+
*/
|
|
4308
|
+
async problem(git) {
|
|
4309
|
+
if (!/^[0-9a-f]{4,64}$/i.test(this.commit)) return `${this.commit} is not a commit id`;
|
|
4310
|
+
if ((await git.run([
|
|
4311
|
+
"cat-file",
|
|
4312
|
+
"-e",
|
|
4313
|
+
`${this.commit}^{commit}`
|
|
4314
|
+
], [1, 128])).succeeded()) return null;
|
|
4315
|
+
return `${this.commit} is not in this repository's history`;
|
|
4316
|
+
}
|
|
4317
|
+
};
|
|
4318
|
+
//#endregion
|
|
4319
|
+
//#region src/lib/git/operations/diff-since.ts
|
|
4320
|
+
/**
|
|
4321
|
+
* The diff of `paths` since `commit`, untracked files as added, as git prints it.
|
|
4322
|
+
*
|
|
4323
|
+
* One `git diff` over all of `paths`. Not one per file: a path matches
|
|
4324
|
+
* everything under it too, so a file replaced by a directory took in the
|
|
4325
|
+
* directory's files, and hundreds of files meant hundreds of processes, which
|
|
4326
|
+
* ran out of open files. Nothing is left out of the diff: the paths are the
|
|
4327
|
+
* caller's to choose, and whatever they name, a secret included, is shown.
|
|
4328
|
+
*/
|
|
4329
|
+
var DiffSince = class {
|
|
4330
|
+
commit;
|
|
4331
|
+
paths;
|
|
4332
|
+
format;
|
|
4333
|
+
/** Diffs `paths` since `commit`, coloured as `options` say, with `extra` added to git's format. */
|
|
4334
|
+
constructor(commit, paths, options, extra = []) {
|
|
4335
|
+
this.commit = commit;
|
|
4336
|
+
this.paths = paths;
|
|
4337
|
+
if (options.color) this.format = ["--color=always", ...extra];
|
|
4338
|
+
else this.format = ["--no-color", ...extra];
|
|
4339
|
+
}
|
|
4340
|
+
/** The commit is in local history. */
|
|
4341
|
+
checks() {
|
|
4342
|
+
return [new CommitIsHere(this.commit)];
|
|
4343
|
+
}
|
|
4344
|
+
/** No diff: there is nothing to diff against. */
|
|
4345
|
+
refused() {
|
|
4346
|
+
return null;
|
|
4347
|
+
}
|
|
4348
|
+
/**
|
|
4349
|
+
* Diffs in a copy of the developer's staging area with untracked files marked as added.
|
|
4350
|
+
*
|
|
4351
|
+
* `git diff` leaves untracked files out, but a new file is part of what a
|
|
4352
|
+
* deploy publishes, so it is marked "intent to add" first, in a copy and
|
|
4353
|
+
* never in the developer's own staging area. When that cannot be done the
|
|
4354
|
+
* plain diff is given instead, which still shows every tracked change.
|
|
4355
|
+
*
|
|
4356
|
+
* `--no-ext-diff` keeps a configured external diff tool out, `--relative`
|
|
4357
|
+
* names paths from the directory, and each path is taken literally
|
|
4358
|
+
* ({@link Git.literal}), so `app/[id].tsx` does not also diff `app/i.tsx`.
|
|
4359
|
+
*/
|
|
4360
|
+
async run(git) {
|
|
4361
|
+
const diff = [
|
|
4362
|
+
"diff",
|
|
4363
|
+
"--no-ext-diff",
|
|
4364
|
+
"--relative",
|
|
4365
|
+
...this.format,
|
|
4366
|
+
this.commit,
|
|
4367
|
+
"--",
|
|
4368
|
+
...this.paths.map(Git.literal)
|
|
4369
|
+
];
|
|
4370
|
+
return StagingArea.temporary(git, async (staging) => {
|
|
4371
|
+
try {
|
|
4372
|
+
await staging.startFromDevelopers(git);
|
|
4373
|
+
await staging.markAsAdded(this.paths);
|
|
4374
|
+
return (await staging.git.run(diff)).stdout;
|
|
4375
|
+
} catch {
|
|
4376
|
+
return (await git.run(diff)).stdout;
|
|
4377
|
+
}
|
|
4378
|
+
});
|
|
4379
|
+
}
|
|
4380
|
+
};
|
|
4381
|
+
//#endregion
|
|
4382
|
+
//#region src/lib/git/operations/diff-files-since.ts
|
|
4383
|
+
/**
|
|
4384
|
+
* The changed files under `paths` since `commit`, untracked files as added, each with its part of the diff.
|
|
4385
|
+
*
|
|
4386
|
+
* Built from the same single `git diff` as {@link DiffSince}, so joined back
|
|
4387
|
+
* together the patches are the diff a terminal shows. `--submodule=short`
|
|
4388
|
+
* keeps a submodule's part under a `diff --git` header, which
|
|
4389
|
+
* `diff.submodule=log` would replace with a `Submodule …` line that belongs
|
|
4390
|
+
* to no file; it is set here only, so the terminal diff stays as the
|
|
4391
|
+
* developer configured it.
|
|
4392
|
+
*/
|
|
4393
|
+
var DiffFilesSince = class {
|
|
4394
|
+
diff;
|
|
4395
|
+
/** Diffs `paths` since `commit`, uncoloured, with the list of changed files ahead of the patch. */
|
|
4396
|
+
constructor(commit, paths) {
|
|
4397
|
+
this.diff = new DiffSince(commit, paths, {}, [
|
|
4398
|
+
"--raw",
|
|
4399
|
+
"-z",
|
|
4400
|
+
"--patch",
|
|
4401
|
+
"--submodule=short"
|
|
4402
|
+
]);
|
|
4403
|
+
}
|
|
4404
|
+
/** Those of the diff. */
|
|
4405
|
+
checks() {
|
|
4406
|
+
return this.diff.checks();
|
|
4407
|
+
}
|
|
4408
|
+
/** No diff: there is nothing to diff against. */
|
|
4409
|
+
refused() {
|
|
4410
|
+
return null;
|
|
4411
|
+
}
|
|
4412
|
+
/**
|
|
4413
|
+
* Each listed file paired with its section of the patch.
|
|
4414
|
+
*
|
|
4415
|
+
* `--raw -z` lists every file's status and paths, unquoted, ahead of the
|
|
4416
|
+
* patch; one more NUL ends the list. git writes the list and the patch from
|
|
4417
|
+
* one list of files, in one order, so they pair one to one. The exception is
|
|
4418
|
+
* a file whose type changed (a file replaced by a symlink): git shows it as
|
|
4419
|
+
* two sections under one header, which are joined. The patch is split only
|
|
4420
|
+
* where `diff --git` follows a newline. A file's content may mention it, and
|
|
4421
|
+
* a file with old Mac line endings (a lone `\r`) has no newline before it,
|
|
4422
|
+
* so its section stays whole.
|
|
4423
|
+
*/
|
|
4424
|
+
async run(git) {
|
|
4425
|
+
const output = await this.diff.run(git);
|
|
4426
|
+
const end = output.indexOf("\0\0");
|
|
4427
|
+
if (end === -1) return [];
|
|
4428
|
+
const patches = [];
|
|
4429
|
+
for (const section of output.slice(end + 2).split(/(?<=\n)(?=diff --git )/)) {
|
|
4430
|
+
const last = patches.at(-1);
|
|
4431
|
+
if (last !== void 0 && last.slice(0, last.indexOf("\n")) === section.slice(0, section.indexOf("\n"))) patches[patches.length - 1] = last + section;
|
|
4432
|
+
else patches.push(section);
|
|
4433
|
+
}
|
|
4434
|
+
return Change.listedIn(output.slice(0, end).split("\0")).map((change, index) => change.diffedWith(patches[index] ?? ""));
|
|
4435
|
+
}
|
|
4436
|
+
};
|
|
4437
|
+
//#endregion
|
|
4438
|
+
//#region src/lib/git/operations/is-dirty.ts
|
|
4439
|
+
/**
|
|
4440
|
+
* Whether anything in the whole repository is uncommitted, not only under the directory.
|
|
4441
|
+
*
|
|
4442
|
+
* Any change anywhere in the repository counts, including outside the
|
|
4443
|
+
* directory the repository was opened on.
|
|
4444
|
+
*/
|
|
4445
|
+
var IsDirty = class {
|
|
4446
|
+
/** Nothing to check. */
|
|
4447
|
+
checks() {
|
|
4448
|
+
return [];
|
|
4449
|
+
}
|
|
4450
|
+
/** Never refused. */
|
|
4451
|
+
refused() {
|
|
4452
|
+
return false;
|
|
4453
|
+
}
|
|
4454
|
+
/** Asks git whether `status` lists anything. */
|
|
4455
|
+
async run(git) {
|
|
4456
|
+
return (await git.run(["status", "--porcelain"])).stdout.trim() !== "";
|
|
4457
|
+
}
|
|
4458
|
+
};
|
|
4459
|
+
//#endregion
|
|
4460
|
+
//#region src/lib/git/operations/ready-to-commit.ts
|
|
4461
|
+
/**
|
|
4462
|
+
* Why a commit here would land where it should not, or null when one would land on a branch as meant.
|
|
4463
|
+
*
|
|
4464
|
+
* The same checks {@link CommitAll} makes, asked on their own, so a caller
|
|
4465
|
+
* can refuse before it does any work rather than find out when it commits.
|
|
4466
|
+
*/
|
|
4467
|
+
var ReadyToCommit = class {
|
|
4468
|
+
/** Nothing is under way, and HEAD is on a branch. */
|
|
4469
|
+
checks() {
|
|
4470
|
+
return [new NoOperationUnderWay(), new HeadIsOnABranch()];
|
|
4471
|
+
}
|
|
4472
|
+
/** The first problem a check found. */
|
|
4473
|
+
refused(problem) {
|
|
4474
|
+
return problem;
|
|
4475
|
+
}
|
|
4476
|
+
/** No problem: every check holds. */
|
|
4477
|
+
async run() {
|
|
4478
|
+
return null;
|
|
4479
|
+
}
|
|
4480
|
+
};
|
|
4481
|
+
//#endregion
|
|
4482
|
+
//#region src/lib/git/operations/uncommitted-files.ts
|
|
4483
|
+
/**
|
|
4484
|
+
* The uncommitted files under the directory, untracked ones included, each file of an untracked directory listed.
|
|
4485
|
+
*
|
|
4486
|
+
* What a commit of the whole directory would sweep in. In a monorepo a
|
|
4487
|
+
* sibling package's work is not listed, since a commit here never touches
|
|
4488
|
+
* it. Each path is named from the directory, as the developer sees it.
|
|
4489
|
+
*/
|
|
4490
|
+
var UncommittedFiles = class {
|
|
4491
|
+
/** Where the directory sits in the repository, e.g. `apps/web/`: what `status` puts before each path. */
|
|
4492
|
+
prefix;
|
|
4493
|
+
/** Lists the files under the directory at `prefix`. */
|
|
4494
|
+
constructor(prefix) {
|
|
4495
|
+
this.prefix = prefix;
|
|
4496
|
+
}
|
|
4497
|
+
/** Nothing to check. */
|
|
4498
|
+
checks() {
|
|
4499
|
+
return [];
|
|
4500
|
+
}
|
|
4501
|
+
/** Never refused. */
|
|
4502
|
+
refused() {
|
|
4503
|
+
return [];
|
|
4504
|
+
}
|
|
4505
|
+
/**
|
|
4506
|
+
* Asks git for the status of the directory.
|
|
4507
|
+
*
|
|
4508
|
+
* `-- .` keeps a sibling package's work out. `--untracked-files=all` names
|
|
4509
|
+
* each file of an untracked directory rather than the directory alone.
|
|
4510
|
+
* `status` names paths from the repository root, so the prefix is taken off;
|
|
4511
|
+
* a rename or a copy carries a second entry, where it came from, which is
|
|
4512
|
+
* skipped.
|
|
4513
|
+
*/
|
|
4514
|
+
async run(git) {
|
|
4515
|
+
const unread = (await git.run([
|
|
4516
|
+
"status",
|
|
4517
|
+
"--porcelain",
|
|
4518
|
+
"-z",
|
|
4519
|
+
"--untracked-files=all",
|
|
4520
|
+
"--",
|
|
4521
|
+
"."
|
|
4522
|
+
])).paths().values();
|
|
4523
|
+
const files = [];
|
|
4524
|
+
for (const entry of unread) {
|
|
4525
|
+
const path = entry.slice(3);
|
|
4526
|
+
const untracked = entry.startsWith("??");
|
|
4527
|
+
if (path.startsWith(this.prefix)) files.push({
|
|
4528
|
+
path: path.slice(this.prefix.length),
|
|
4529
|
+
untracked
|
|
4530
|
+
});
|
|
4531
|
+
else files.push({
|
|
4532
|
+
path,
|
|
4533
|
+
untracked
|
|
4534
|
+
});
|
|
4535
|
+
if (/^(R|C|.R|.C)/.test(entry)) unread.next();
|
|
4536
|
+
}
|
|
4537
|
+
return files;
|
|
4538
|
+
}
|
|
4539
|
+
};
|
|
4540
|
+
//#endregion
|
|
4541
|
+
//#region src/lib/git/repository.ts
|
|
4542
|
+
/**
|
|
4543
|
+
* The git repository a directory is in, seen from that directory.
|
|
4544
|
+
*
|
|
4545
|
+
* In a monorepo the repository may start several levels up; everything here
|
|
4546
|
+
* is limited to the directory and names paths relative to it, except
|
|
4547
|
+
* {@link isDirty}, which looks at the whole repository. When git fails, a
|
|
4548
|
+
* method throws a `RepositoryError` carrying git's own message.
|
|
4549
|
+
*
|
|
4550
|
+
* Each method is one {@link Operation}: its checks run first, and the first
|
|
4551
|
+
* one that fails decides the answer through the operation's `refused`.
|
|
4552
|
+
*/
|
|
4553
|
+
var Repository = class Repository {
|
|
4554
|
+
git;
|
|
4555
|
+
/** Where the directory sits in the repository, e.g. `apps/web/`, or `""` at its root. */
|
|
4556
|
+
prefix;
|
|
4557
|
+
/** Creates the handle; use {@link Repository.open} or {@link Repository.create}. */
|
|
4558
|
+
constructor(dir, prefix) {
|
|
4559
|
+
this.git = new Git(dir);
|
|
4560
|
+
this.prefix = prefix;
|
|
4561
|
+
}
|
|
4562
|
+
/**
|
|
4563
|
+
* The repository `dir` is in, or null when it is in none, does not exist, or there is no git.
|
|
4564
|
+
*
|
|
4565
|
+
* Only those three mean "no repository". Any other failure, such as a
|
|
4566
|
+
* refusal over ownership or a broken `HOME`, is thrown: answering "none"
|
|
4567
|
+
* would let a caller `git init` a second repository inside the first, or
|
|
4568
|
+
* skip a check that the tree is clean.
|
|
4569
|
+
*
|
|
4570
|
+
* `dir` is resolved to its real path first: git answers paths such as
|
|
4571
|
+
* `--git-path MERGE_HEAD` relative to where it really runs, so through a
|
|
4572
|
+
* symlink they would point somewhere else. git is asked in English here,
|
|
4573
|
+
* since "not a git repository" is recognised by its wording.
|
|
4574
|
+
*/
|
|
4575
|
+
static async open(dir) {
|
|
4576
|
+
if (!existsSync(dir)) return null;
|
|
4577
|
+
const real = await realpath(dir);
|
|
4578
|
+
const result = await new Git(real).with({ LC_ALL: "C" }).run(["rev-parse", "--show-prefix"], [128]).catch((error) => {
|
|
4579
|
+
if (error instanceof GitDidNotStartError) return null;
|
|
4580
|
+
throw error;
|
|
4581
|
+
});
|
|
4582
|
+
if (result === null) return null;
|
|
4583
|
+
if (/^fatal: not a git repository/m.test(result.stderr)) return null;
|
|
4584
|
+
if (!result.succeeded()) throw new RepositoryError(result.reason());
|
|
4585
|
+
return new Repository(real, result.text());
|
|
4586
|
+
}
|
|
4587
|
+
/**
|
|
4588
|
+
* What `question` answers about the repository `dir` is in, or `otherwise` when there is none or git cannot answer.
|
|
4589
|
+
*
|
|
4590
|
+
* For a caller that records what git can tell and must never fail because
|
|
4591
|
+
* of it, such as one that notes a commit when there is one: every failure,
|
|
4592
|
+
* a missing git included, becomes `otherwise`. A caller that needs to know
|
|
4593
|
+
* why git failed uses {@link open} and the methods directly.
|
|
4594
|
+
*/
|
|
4595
|
+
static async answer(dir, question, otherwise) {
|
|
4596
|
+
try {
|
|
4597
|
+
const repository = await Repository.open(dir);
|
|
4598
|
+
if (repository === null) return otherwise;
|
|
4599
|
+
return await question(repository);
|
|
4600
|
+
} catch {
|
|
4601
|
+
return otherwise;
|
|
4602
|
+
}
|
|
4603
|
+
}
|
|
4604
|
+
/**
|
|
4605
|
+
* A new repository in `dir`; when git cannot make one, nothing is left behind.
|
|
4606
|
+
*
|
|
4607
|
+
* `git init` writes `.git` before it is done, so a failure partway (a full
|
|
4608
|
+
* or read-only disk) leaves a half-made repository that every later git
|
|
4609
|
+
* command would trip over. When `dir` had no `.git` before, a failed
|
|
4610
|
+
* `git init` removes it again, so a caller gets either a working repository
|
|
4611
|
+
* or an error, never something in between. The error is always git's: if
|
|
4612
|
+
* the removal fails too, its own failure is dropped, since git's message
|
|
4613
|
+
* names the real cause. This assumes nothing else creates a repository in
|
|
4614
|
+
* `dir` at the same time.
|
|
4615
|
+
*
|
|
4616
|
+
* A `.git` counts as there before even when it is a symlink to something
|
|
4617
|
+
* missing, so a developer's link to an unmounted volume is never removed.
|
|
4618
|
+
*/
|
|
4619
|
+
static async create(dir) {
|
|
4620
|
+
const real = await realpath(dir);
|
|
4621
|
+
const gitDir = join(real, ".git");
|
|
4622
|
+
const hadOne = await lstat(gitDir).then(() => true, () => false);
|
|
4623
|
+
try {
|
|
4624
|
+
await new Git(real).run(["init", "--quiet"]);
|
|
4625
|
+
} catch (error) {
|
|
4626
|
+
if (!hadOne) await rm(gitDir, {
|
|
4627
|
+
recursive: true,
|
|
4628
|
+
force: true
|
|
4629
|
+
}).catch(() => void 0);
|
|
4630
|
+
throw error;
|
|
4631
|
+
}
|
|
4632
|
+
return new Repository(real, "");
|
|
4633
|
+
}
|
|
4634
|
+
/** The commit it is at, or null before its first commit. */
|
|
4635
|
+
currentCommit() {
|
|
4636
|
+
return this.perform(new CurrentCommit());
|
|
4637
|
+
}
|
|
4638
|
+
/** The subject line of the commit it is at, or null before its first commit. */
|
|
4639
|
+
currentSubject() {
|
|
4640
|
+
return this.perform(new CurrentSubject());
|
|
4641
|
+
}
|
|
4642
|
+
/** Whether anything in the whole repository is uncommitted. */
|
|
4643
|
+
isDirty() {
|
|
4644
|
+
return this.perform(new IsDirty());
|
|
4645
|
+
}
|
|
4646
|
+
/** The uncommitted files under the directory, untracked ones included. */
|
|
4647
|
+
uncommittedFiles() {
|
|
4648
|
+
return this.perform(new UncommittedFiles(this.prefix));
|
|
4649
|
+
}
|
|
4650
|
+
/** Why a commit here would land where it should not (an operation under way, a detached HEAD), or null. */
|
|
4651
|
+
readyToCommit() {
|
|
4652
|
+
return this.perform(new ReadyToCommit());
|
|
4653
|
+
}
|
|
4654
|
+
/**
|
|
4655
|
+
* Commits everything under the directory but `options.except`; see {@link CommitAll}.
|
|
4656
|
+
*
|
|
4657
|
+
* Meant for a directory with nothing uncommitted of the developer's own:
|
|
4658
|
+
* check {@link uncommittedFiles} first. Afterwards the developer's staging
|
|
4659
|
+
* area under the directory is reset to the new commit, so anything they had
|
|
4660
|
+
* staged there would be unstaged.
|
|
4661
|
+
*/
|
|
4662
|
+
commitAll(message, options) {
|
|
4663
|
+
return this.perform(new CommitAll(message, options));
|
|
4664
|
+
}
|
|
4665
|
+
/** The diff of `paths` since `commit`, untracked files as added, or null when that commit is not here. */
|
|
4666
|
+
diffSince(commit, paths, options) {
|
|
4667
|
+
return this.perform(new DiffSince(commit, paths, options));
|
|
4668
|
+
}
|
|
4669
|
+
/** {@link diffSince}, uncoloured and split into one entry per changed file. */
|
|
4670
|
+
diffFilesSince(commit, paths) {
|
|
4671
|
+
return this.perform(new DiffFilesSince(commit, paths));
|
|
4672
|
+
}
|
|
4673
|
+
/** Runs `operation` once all its checks hold; what it returns when refused, otherwise. */
|
|
4674
|
+
async perform(operation) {
|
|
4675
|
+
for (const check of operation.checks()) {
|
|
4676
|
+
const problem = await check.problem(this.git);
|
|
4677
|
+
if (problem !== null) return operation.refused(problem);
|
|
4678
|
+
}
|
|
4679
|
+
return operation.run(this.git);
|
|
4680
|
+
}
|
|
4681
|
+
};
|
|
4682
|
+
//#endregion
|
|
3464
4683
|
//#region src/lib/release/git.ts
|
|
3465
|
-
const run = promisify(execFile);
|
|
3466
4684
|
/**
|
|
3467
4685
|
* The directories a release is built from, the only paths a deploy diffs. Not
|
|
3468
4686
|
* the whole of `.zitadel/`: `state.json` and `meta/` are not published, and
|
|
@@ -3475,26 +4693,13 @@ const CONFIG_PATHS = [
|
|
|
3475
4693
|
".zitadel/branding/",
|
|
3476
4694
|
".zitadel/idps/"
|
|
3477
4695
|
];
|
|
3478
|
-
/** Run git in `cwd`, returning its stdout, or null when git cannot answer. */
|
|
3479
|
-
async function git(cwd, args, env) {
|
|
3480
|
-
try {
|
|
3481
|
-
const { stdout } = await run("git", args, {
|
|
3482
|
-
cwd,
|
|
3483
|
-
maxBuffer: 16777216,
|
|
3484
|
-
...env ? { env: {
|
|
3485
|
-
...process.env,
|
|
3486
|
-
...env
|
|
3487
|
-
} } : {}
|
|
3488
|
-
});
|
|
3489
|
-
return stdout;
|
|
3490
|
-
} catch {
|
|
3491
|
-
return null;
|
|
3492
|
-
}
|
|
3493
|
-
}
|
|
3494
4696
|
/** The current commit, or null when there is no repo or no commit yet. */
|
|
3495
4697
|
async function headSha(cwd) {
|
|
3496
|
-
|
|
3497
|
-
|
|
4698
|
+
return Repository.answer(cwd, (repository) => repository.currentCommit(), null);
|
|
4699
|
+
}
|
|
4700
|
+
/** The current commit's subject line, or null when there is no repo or no commit yet. */
|
|
4701
|
+
async function headSubject(cwd) {
|
|
4702
|
+
return Repository.answer(cwd, (repository) => repository.currentSubject(), null);
|
|
3498
4703
|
}
|
|
3499
4704
|
/**
|
|
3500
4705
|
* Whether the repository's working tree has any uncommitted change, anywhere —
|
|
@@ -3503,24 +4708,16 @@ async function headSha(cwd) {
|
|
|
3503
4708
|
* the config depends on, say) makes that provenance just as inexact.
|
|
3504
4709
|
*/
|
|
3505
4710
|
async function worktreeIsDirty(cwd) {
|
|
3506
|
-
|
|
3507
|
-
return stdout !== null && stdout.trim() !== "";
|
|
4711
|
+
return Repository.answer(cwd, (repository) => repository.isDirty(), false);
|
|
3508
4712
|
}
|
|
3509
4713
|
/**
|
|
3510
|
-
* The `git diff` options the terminal's diff and the `--json` one share, so
|
|
3511
|
-
* the two never disagree: git's own unified diff, never an external diff
|
|
3512
|
-
* program's, with paths relative to the project, as the CLI reports them
|
|
3513
|
-
* everywhere else.
|
|
3514
|
-
*/
|
|
3515
|
-
const DIFF_OPTIONS = ["--no-ext-diff", "--relative"];
|
|
3516
|
-
/**
|
|
3517
4714
|
* The coloured diff of `.zitadel/` against `sha`, or null when no diff can be
|
|
3518
4715
|
* shown: git is missing, this is not a repo, or the commit is not in local
|
|
3519
|
-
* history (a shallow clone, say).
|
|
3520
|
-
* by first checking the commit resolves.
|
|
4716
|
+
* history (a shallow clone, say).
|
|
3521
4717
|
*/
|
|
3522
4718
|
async function diffConfigAgainst(cwd, sha, color, referenced = []) {
|
|
3523
|
-
|
|
4719
|
+
const paths = [...CONFIG_PATHS, ...referenced];
|
|
4720
|
+
return Repository.answer(cwd, (repository) => repository.diffSince(sha, paths, { color }), null);
|
|
3524
4721
|
}
|
|
3525
4722
|
/**
|
|
3526
4723
|
* {@link diffConfigAgainst}, uncoloured and split by file: joined back
|
|
@@ -3528,106 +4725,30 @@ async function diffConfigAgainst(cwd, sha, color, referenced = []) {
|
|
|
3528
4725
|
* be shown, for the same reasons.
|
|
3529
4726
|
*/
|
|
3530
4727
|
async function diffConfigFilesAgainst(cwd, sha, referenced = []) {
|
|
3531
|
-
const output = await diffConfig(cwd, sha, [
|
|
3532
|
-
"--no-color",
|
|
3533
|
-
"--raw",
|
|
3534
|
-
"-z",
|
|
3535
|
-
"--patch"
|
|
3536
|
-
], referenced);
|
|
3537
|
-
return output === null ? null : splitByFile(output);
|
|
3538
|
-
}
|
|
3539
|
-
/**
|
|
3540
|
-
* Pair the `--raw -z` records with the patch's per-file sections. git writes
|
|
3541
|
-
* both from one list of files, in one order, so they line up one to one, except
|
|
3542
|
-
* for a file whose type changed (a file replaced by a symlink): git shows it as
|
|
3543
|
-
* a deletion and a creation, two sections under one header, joined here.
|
|
3544
|
-
*/
|
|
3545
|
-
function splitByFile(output) {
|
|
3546
|
-
let at = 0;
|
|
3547
|
-
const field = () => {
|
|
3548
|
-
const end = output.indexOf("\0", at);
|
|
3549
|
-
const value = output.slice(at, end === -1 ? output.length : end);
|
|
3550
|
-
at = end === -1 ? output.length : end + 1;
|
|
3551
|
-
return value;
|
|
3552
|
-
};
|
|
3553
|
-
const records = [];
|
|
3554
|
-
while (output.startsWith(":", at)) {
|
|
3555
|
-
const meta = field();
|
|
3556
|
-
const letter = meta.charAt(meta.lastIndexOf(" ") + 1);
|
|
3557
|
-
const path = field();
|
|
3558
|
-
if (letter === "R" || letter === "C") {
|
|
3559
|
-
const status = letter === "R" ? "renamed" : "copied";
|
|
3560
|
-
records.push({
|
|
3561
|
-
path: field(),
|
|
3562
|
-
status,
|
|
3563
|
-
previous_path: path
|
|
3564
|
-
});
|
|
3565
|
-
} else {
|
|
3566
|
-
const status = letter === "A" ? "added" : letter === "D" ? "deleted" : "modified";
|
|
3567
|
-
records.push({
|
|
3568
|
-
path,
|
|
3569
|
-
status
|
|
3570
|
-
});
|
|
3571
|
-
}
|
|
3572
|
-
}
|
|
3573
|
-
const sections = output.slice(at + 1).split(/^(?=diff --git )/m);
|
|
3574
|
-
let next = 0;
|
|
3575
|
-
return records.map((record) => {
|
|
3576
|
-
let patch = sections[next++] ?? "";
|
|
3577
|
-
while (next < sections.length && headerOf(sections[next]) === headerOf(patch)) patch += sections[next++];
|
|
3578
|
-
return {
|
|
3579
|
-
...record,
|
|
3580
|
-
patch
|
|
3581
|
-
};
|
|
3582
|
-
});
|
|
3583
|
-
}
|
|
3584
|
-
/** A patch section's first line, its `diff --git` header. */
|
|
3585
|
-
function headerOf(section = "") {
|
|
3586
|
-
const end = section.indexOf("\n");
|
|
3587
|
-
return end === -1 ? section : section.slice(0, end);
|
|
3588
|
-
}
|
|
3589
|
-
/** `git diff` of the release's paths against `sha`, in `format`. */
|
|
3590
|
-
async function diffConfig(cwd, sha, format, referenced) {
|
|
3591
4728
|
const paths = [...CONFIG_PATHS, ...referenced];
|
|
3592
|
-
|
|
3593
|
-
|
|
3594
|
-
|
|
3595
|
-
|
|
3596
|
-
|
|
3597
|
-
|
|
3598
|
-
|
|
3599
|
-
|
|
3600
|
-
|
|
3601
|
-
|
|
3602
|
-
|
|
3603
|
-
|
|
3604
|
-
|
|
3605
|
-
const
|
|
3606
|
-
|
|
3607
|
-
|
|
3608
|
-
|
|
3609
|
-
|
|
3610
|
-
|
|
3611
|
-
|
|
3612
|
-
|
|
3613
|
-
scratch = await mkdtemp(join(tmpdir(), "zitadel-diff-"));
|
|
3614
|
-
const tempIndex = join(scratch, "index");
|
|
3615
|
-
await copyFile(resolve(cwd, indexPath), tempIndex).catch(() => void 0);
|
|
3616
|
-
const env = { GIT_INDEX_FILE: tempIndex };
|
|
3617
|
-
const present = paths.filter((path) => existsSync(join(cwd, path)));
|
|
3618
|
-
if (present.length > 0) await git(cwd, [
|
|
3619
|
-
"add",
|
|
3620
|
-
"-N",
|
|
3621
|
-
"--",
|
|
3622
|
-
...present
|
|
3623
|
-
], env);
|
|
3624
|
-
return await git(cwd, diffArgs, env) ?? await git(cwd, diffArgs);
|
|
3625
|
-
} finally {
|
|
3626
|
-
if (scratch) await rm(scratch, {
|
|
3627
|
-
recursive: true,
|
|
3628
|
-
force: true
|
|
3629
|
-
});
|
|
4729
|
+
return Repository.answer(cwd, (repository) => repository.diffFilesSince(sha, paths), null);
|
|
4730
|
+
}
|
|
4731
|
+
//#endregion
|
|
4732
|
+
//#region src/lib/release/pipeline.ts
|
|
4733
|
+
/**
|
|
4734
|
+
* Run the before-release steps in order, skipping the ones that do not apply to
|
|
4735
|
+
* this publish or that write on a dry run, and return what they report, for the
|
|
4736
|
+
* command's `--json` data. The command creates the release and the deployment
|
|
4737
|
+
* itself, between and after the step groups: before-release steps → create
|
|
4738
|
+
* release → before-deploy steps → create deployment.
|
|
4739
|
+
*/
|
|
4740
|
+
async function runSteps(steps, ctx) {
|
|
4741
|
+
let report = {};
|
|
4742
|
+
for (const step of steps) {
|
|
4743
|
+
if (!step.runsFor.includes(ctx.mode)) continue;
|
|
4744
|
+
if (ctx.dryRun && step.writes) continue;
|
|
4745
|
+
const reported = await step.run(ctx);
|
|
4746
|
+
if (reported) report = {
|
|
4747
|
+
...report,
|
|
4748
|
+
...reported
|
|
4749
|
+
};
|
|
3630
4750
|
}
|
|
4751
|
+
return report;
|
|
3631
4752
|
}
|
|
3632
4753
|
//#endregion
|
|
3633
4754
|
//#region src/lib/release/steps/asset-probe.ts
|
|
@@ -4237,6 +5358,97 @@ function assertEnvRefsResolve(ctx) {
|
|
|
4237
5358
|
if (missing.size > 0) throw new ZitadelError("E_VALIDATION", `Missing environment variables: ${[...missing].sort().join(", ")}`);
|
|
4238
5359
|
}
|
|
4239
5360
|
//#endregion
|
|
5361
|
+
//#region src/lib/release/initial-deploy.ts
|
|
5362
|
+
/**
|
|
5363
|
+
* Publish `.zitadel/` to a project nothing has been deployed to, the way
|
|
5364
|
+
* `deploy` does: build the bundle, validate it, create the release and deploy
|
|
5365
|
+
* it to every primary origin. `deploy`'s diff step is left out, because there
|
|
5366
|
+
* is no live release to diff against, and so is its optimistic guard, because
|
|
5367
|
+
* there is no deployment to race with.
|
|
5368
|
+
*/
|
|
5369
|
+
async function deployInitialRelease(input) {
|
|
5370
|
+
const { bundle, brandingSources, idpFiles, policyFiles } = await buildBundle(input.cwd);
|
|
5371
|
+
const ctx = {
|
|
5372
|
+
cwd: input.cwd,
|
|
5373
|
+
client: input.client,
|
|
5374
|
+
projectId: input.projectId,
|
|
5375
|
+
env: input.env,
|
|
5376
|
+
mode: "deploy",
|
|
5377
|
+
dryRun: false,
|
|
5378
|
+
json: input.json,
|
|
5379
|
+
color: input.color,
|
|
5380
|
+
bundle,
|
|
5381
|
+
brandingSources,
|
|
5382
|
+
idpFiles,
|
|
5383
|
+
policyFiles,
|
|
5384
|
+
suggest: input.suggest
|
|
5385
|
+
};
|
|
5386
|
+
await runSteps([validateStep], ctx);
|
|
5387
|
+
const release = await createReleaseFromBundle(input.client, input.projectId, bundle, {
|
|
5388
|
+
message: input.message,
|
|
5389
|
+
gitSha: await headSha(input.cwd),
|
|
5390
|
+
gitDirty: await worktreeIsDirty(input.cwd)
|
|
5391
|
+
});
|
|
5392
|
+
const deployment = await createDeployment(input.client, input.projectId, {
|
|
5393
|
+
releaseId: release.id,
|
|
5394
|
+
targets: ["primary"],
|
|
5395
|
+
message: input.message
|
|
5396
|
+
});
|
|
5397
|
+
return {
|
|
5398
|
+
releaseId: release.id,
|
|
5399
|
+
contentHash: release.content_hash,
|
|
5400
|
+
deploymentId: deployment.id,
|
|
5401
|
+
origins: deployment.targets.map((target) => target.origin)
|
|
5402
|
+
};
|
|
5403
|
+
}
|
|
5404
|
+
//#endregion
|
|
5405
|
+
//#region src/lib/release/message.ts
|
|
5406
|
+
/** The longest message the server records on a release or a deployment, in code points. */
|
|
5407
|
+
const MESSAGE_MAX_LENGTH = 1024;
|
|
5408
|
+
/** `-m/--message`: refused when blank or longer than the server records. */
|
|
5409
|
+
function messageFlag(description) {
|
|
5410
|
+
return Flags.custom({
|
|
5411
|
+
char: "m",
|
|
5412
|
+
description,
|
|
5413
|
+
parse: async (input) => {
|
|
5414
|
+
const value = refuseBlank(input, "--message", "the value is empty");
|
|
5415
|
+
if (Array.from(value).length > MESSAGE_MAX_LENGTH) throw new ZitadelError("E_VALIDATION", `the message is longer than ${MESSAGE_MAX_LENGTH} characters`, {
|
|
5416
|
+
hint: "Pass a shorter summary with -m.",
|
|
5417
|
+
details: { argument: "--message" }
|
|
5418
|
+
});
|
|
5419
|
+
return value;
|
|
5420
|
+
}
|
|
5421
|
+
})();
|
|
5422
|
+
}
|
|
5423
|
+
/**
|
|
5424
|
+
* The message a deploy or preview records: `-m`, else what the build is of —
|
|
5425
|
+
* the pull request's title on GitHub, Vercel's commit message, or the subject
|
|
5426
|
+
* of the current commit when the worktree is clean.
|
|
5427
|
+
*/
|
|
5428
|
+
async function deployMessage(flag, cwd, env) {
|
|
5429
|
+
if (flag !== void 0) return flag;
|
|
5430
|
+
if (env.GITHUB_EVENT_NAME === "pull_request" || env.GITHUB_EVENT_NAME === "pull_request_target") return cut(await pullRequestTitle(env.GITHUB_EVENT_PATH));
|
|
5431
|
+
const vercel = env.VERCEL_GIT_COMMIT_MESSAGE?.split("\n")[0];
|
|
5432
|
+
if (vercel?.trim()) return cut(vercel);
|
|
5433
|
+
if (await worktreeIsDirty(cwd)) return;
|
|
5434
|
+
return cut(await headSubject(cwd) ?? void 0);
|
|
5435
|
+
}
|
|
5436
|
+
/** The title in a GitHub pull_request event payload, or undefined when it cannot be read. */
|
|
5437
|
+
async function pullRequestTitle(path) {
|
|
5438
|
+
if (!path) return;
|
|
5439
|
+
try {
|
|
5440
|
+
const title = JSON.parse(await readFile(path, "utf8")).pull_request?.title;
|
|
5441
|
+
return typeof title === "string" ? title : void 0;
|
|
5442
|
+
} catch {
|
|
5443
|
+
return;
|
|
5444
|
+
}
|
|
5445
|
+
}
|
|
5446
|
+
/** Trimmed and cut to the server's limit by code points, or undefined when blank. */
|
|
5447
|
+
function cut(value) {
|
|
5448
|
+
const trimmed = value?.trim();
|
|
5449
|
+
return trimmed ? Array.from(trimmed).slice(0, MESSAGE_MAX_LENGTH).join("") : void 0;
|
|
5450
|
+
}
|
|
5451
|
+
//#endregion
|
|
4240
5452
|
//#region src/lib/release/steps/diff.ts
|
|
4241
5453
|
/**
|
|
4242
5454
|
* Show what `.zitadel/` changed since the release the target serves now.
|
|
@@ -4254,7 +5466,11 @@ const diffStep = {
|
|
|
4254
5466
|
name: "diff",
|
|
4255
5467
|
runsFor: ["deploy", "preview"],
|
|
4256
5468
|
async run(ctx) {
|
|
4257
|
-
const deployments = await
|
|
5469
|
+
const deployments = await ctx.client.listDeployments.all({
|
|
5470
|
+
project_id: ctx.projectId,
|
|
5471
|
+
live: true,
|
|
5472
|
+
expand: ["release"]
|
|
5473
|
+
});
|
|
4258
5474
|
const live = deployments.find(servesPrimary)?.release?.metadata;
|
|
4259
5475
|
const sha = live?.git_sha;
|
|
4260
5476
|
if (!sha) return noDiff(ctx, null, "No diff available: nothing is deployed yet, or the live release has no commit.");
|
|
@@ -4294,9 +5510,9 @@ function noDiff(ctx, against, note) {
|
|
|
4294
5510
|
//#endregion
|
|
4295
5511
|
//#region src/lib/release/index.ts
|
|
4296
5512
|
/**
|
|
4297
|
-
* The arguments of a suggested publish. `deploy`
|
|
4298
|
-
*
|
|
4299
|
-
*
|
|
5513
|
+
* The arguments of a suggested publish. A non-interactive `deploy` with no
|
|
5514
|
+
* message to fall back on refuses to run without `-m`, so a suggested deploy
|
|
5515
|
+
* carries one for the reader to fill in.
|
|
4300
5516
|
*/
|
|
4301
5517
|
const DEPLOY_ARGS = "deploy -m '<what changed>'";
|
|
4302
5518
|
//#endregion
|
|
@@ -4375,7 +5591,7 @@ function exploreAllCommands(cliVersion) {
|
|
|
4375
5591
|
* the run was about.
|
|
4376
5592
|
*/
|
|
4377
5593
|
function customizeAndPublishActions(cliVersion, selector) {
|
|
4378
|
-
return ["Customize what you ask for and how people sign in: edit .zitadel/schemas/ to change the information you collect, and .zitadel/flows/ to change the sign-up/sign-in experience. Each folder has a README with examples.", `See your changes before they go live: ${projectCommand("deploy --dry-run", selector, cliVersion)} to preview, then ${projectCommand(DEPLOY_ARGS, selector, cliVersion)} to publish.
|
|
5594
|
+
return ["Customize what you ask for and how people sign in: edit .zitadel/schemas/ to change the information you collect, and .zitadel/flows/ to change the sign-up/sign-in experience. Each folder has a README with examples.", `See your changes before they go live: ${projectCommand("deploy --dry-run", selector, cliVersion)} to preview, then ${projectCommand(DEPLOY_ARGS, selector, cliVersion)} to publish. The project serves only what is deployed, so an edit takes effect once you deploy it.`];
|
|
4379
5595
|
}
|
|
4380
5596
|
/**
|
|
4381
5597
|
* How to change the login's look after setup. Setup never ejects a template
|
|
@@ -4390,11 +5606,10 @@ function brandingGuidanceAction(cliVersion) {
|
|
|
4390
5606
|
//#endregion
|
|
4391
5607
|
//#region src/lib/skills-hint.ts
|
|
4392
5608
|
const SHOWN_FILE = "skills-hint.json";
|
|
4393
|
-
const INSTALL_COMMAND = "npx skills add zitadel/nextgen --full-depth";
|
|
4394
5609
|
/**
|
|
4395
5610
|
* Tell a developer who has AI coding agents installed how to give them the
|
|
4396
5611
|
* Zitadel skills, once per machine and after the command's own next steps.
|
|
4397
|
-
* The
|
|
5612
|
+
* The note adds nothing itself: it names `zitadel skills add` and the
|
|
4398
5613
|
* developer runs it. Modelled on Wrangler's skills suggestion, minus the prompt.
|
|
4399
5614
|
*
|
|
4400
5615
|
* The marker lives in the user's CLI config directory (`telemetryConfigDir`),
|
|
@@ -4407,7 +5622,7 @@ const INSTALL_COMMAND = "npx skills add zitadel/nextgen --full-depth";
|
|
|
4407
5622
|
* fail the command the user actually ran, and losing the race to create the marker
|
|
4408
5623
|
* (`EEXIST`) just means another run is showing the note.
|
|
4409
5624
|
*/
|
|
4410
|
-
async function showSkillsHint({ env, interactive }) {
|
|
5625
|
+
async function showSkillsHint({ env, interactive, cliVersion }) {
|
|
4411
5626
|
try {
|
|
4412
5627
|
if (!interactive || isCI(env) || isOptedOut(env)) return;
|
|
4413
5628
|
const shownPath = join(telemetryConfigDir(env), SHOWN_FILE);
|
|
@@ -4419,9 +5634,9 @@ async function showSkillsHint({ env, interactive }) {
|
|
|
4419
5634
|
consola$1.log([
|
|
4420
5635
|
"",
|
|
4421
5636
|
"Using an AI coding agent?",
|
|
4422
|
-
"
|
|
5637
|
+
"Add the Zitadel skills to your project to help your agent work with Zitadel’s CLI and authentication integrations:",
|
|
4423
5638
|
"",
|
|
4424
|
-
` ${command(
|
|
5639
|
+
` ${command(publicCliCommand("skills add", cliVersion))}`,
|
|
4425
5640
|
"",
|
|
4426
5641
|
"Optional. You can continue without installing skills."
|
|
4427
5642
|
].join("\n"));
|
|
@@ -4439,6 +5654,6 @@ function isOptedOut(env) {
|
|
|
4439
5654
|
return value !== void 0 && value !== "" && value !== "0" && value !== "false";
|
|
4440
5655
|
}
|
|
4441
5656
|
//#endregion
|
|
4442
|
-
export {
|
|
5657
|
+
export { republishCommands as $, processUserAgentFacts as $t, isPolicyTestFile as A, sanitizeResponse as An, checkFlow as At, CommandGroups as B, valuesOfKind as Bt, createReleaseFromBundle as C, parseJsonObject as Cn, checkCustomSlug as Ct, PASSWORD_POLICY_PATH as D, createZitadelClient$1 as Dn, refuseChangedIssuer as Dt, PASSWORD_POLICY_OPERATION as E, REQUEST_TIMEOUT_MS as En, readConnectionFiles as Et, toPolicyWireBody as F, isErrno$1 as Fn, parseVariableValue as Ft, readFlowFiles as G, renderDetail as Gt, authMethods as H, variableInventoryCells as Ht, readJsonDir as I, isMissingEndpoint as In, readStdin as It, SCHEMAS_DIR as J, renderTable as Jt, readSchemaFiles as K, renderList as Kt, readJsonDirEntries as L, toZitadelError as Ln, toInventoryRows as Lt, passwordPolicyFile as M, listenForInterrupt as Mn, VARIABLE_TYPES as Mt, policyFileIssues as N, stopListeningForInterrupt as Nn, assertVariableName as Nt, POLICIES_DIR as O, escapeControlCharacters as On, storedCustomSettings as Ot, policyFileName as P, ZitadelError as Pn, frozenVariables as Pt, republishArgs as Q, installUserAgent as Qt, BRANDING_DIR as R, toVariableRow as Rt, servesPrimary as S, isObject$1 as Sn, IDPS_DIR as St, buildBundle as T, stableStringify as Tn, planConnection as Tt, enabledMethods as U, variableReadScope as Ut, compareInGroup as V, variableCells as Vt, methodsOf as W, variableScope as Wt, reportClientIdOutcome as X, takeReportedWarnings as Xt, publishClientId as Y, reportWarning as Yt, reportSecretOutcome as Z, buildUserAgent as Zt, worktreeIsDirty as _, normalizePublicCliJson as _n, removeSsoFromFlow as _t, serverLogAction as a, nonBlankArg as an, SETUP_FLAG_NAMES as at, createDeployment as b, publicCliCommand as bn, ssoProvidersRefusal as bt, wrapForBox as c, inShell as cn, customFlagText as ct, deployMessage as d, shellCommand as dn, givenCustomFlags as dt, hostAgent as en, storeClientSecret as et, messageFlag as f, unscopedCommand as fn, loopbackIssuerWarning as ft, headSha as g, normalizePublicCliCommands as gn, applySsoToSchema as gt, runSteps as h, maskPublicCliVersion as hn, applySsoToFlow as ht, exploreAllCommands as i, envEnabled as in, SETUP_FLAG_KEYS as it, parsePasswordMinLength as j, interruptSignal as jn, offeredSignInMethods as jt, POLICY_TEST_SUFFIX as k, sanitizeRejection as kn, FLOWS_DIR as kt, DEPLOY_ARGS as l, portableSuggestions as ln, customFlagValues as lt, validateStep as m, isTrainVersion as mn, ssoEnableArgs as mt, brandingGuidanceAction as n, loadOrCreateIdentity as nn, ENABLE_FLAG_KEYS as nt, verifyLoginAction as o, nonBlankString as on, checkGivenCustomFlags as ot, deployInitialRelease as p, isPortableShellWord as pn, selectionFromFlags as pt, selectSchema as q, renderRows as qt, customizeAndPublishActions as r, resolveConsent as rn, ENABLE_FLAG_NAMES as rt, renderBoxActions as s, asEnvironmentSelector as sn, customExample as st, showSkillsHint as t, ciProvider as tn, askConnectionEndpoints as tt, diffStep as u, projectCommand as un, flagText as ut, Repository as v, normalizePublicCliProse as vn, removeSsoFromSchema as vt, revokeRelease as w, setTopLevelJsonKey as wn, credentialVariablesOf as wt, rollbackDeployment as x, sdkVersionForCli as xn, CONNECTION_SCHEMA_REF as xt, RepositoryError as y, optionArgs as yn, ssoEditRefusal as yt, COMMAND_GROUPS as z, toVariableRows as zt };
|
|
4443
5658
|
|
|
4444
|
-
//# sourceMappingURL=skills-hint-
|
|
5659
|
+
//# sourceMappingURL=skills-hint-C8oe8cCo.mjs.map
|