hammerkit 1.22.0 → 1.24.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/build.schema.json +92 -2
- package/dist/cli.js +25 -0
- package/dist/executer/environment-mock.js +4 -1
- package/dist/executer/execute-command.js +7 -1
- package/dist/index.js +4 -1
- package/dist/planner/service-definition.js +3 -1
- package/dist/planner/utils/append-work-service.js +1 -1
- package/dist/planner/utils/append-work-task.js +2 -1
- package/dist/planner/work-item-status.js +3 -2
- package/dist/planner/work-secret.js +87 -10
- package/dist/program.js +4 -0
- package/dist/remote/bundle.js +59 -0
- package/dist/remote/collect-bundle.js +41 -0
- package/dist/remote/credentials.js +93 -0
- package/dist/remote/device-login.js +177 -0
- package/dist/remote/host-config.js +170 -0
- package/dist/remote/location.js +46 -0
- package/dist/remote/remote-commands.js +154 -0
- package/dist/remote/remote-work-commands.js +152 -0
- package/dist/remote/server-client.js +88 -0
- package/dist/remote/source-manifest.js +79 -0
- package/dist/remote/text-diff.js +89 -0
- package/dist/schema/build-file-base-task-schema.js +6 -0
- package/dist/schema/build-file-container-service-schema.js +2 -0
- package/dist/schema/build-file-schema.js +6 -0
- package/dist/schema/build-file-secret-provider-schema.js +36 -0
- package/dist/schema/build-file-secret-schema.js +6 -3
- package/dist/schema/reference-parser.js +4 -0
- package/dist/schema/runner-schema.js +13 -0
- package/dist/secrets/command-secret-provider.js +108 -0
- package/dist/secrets/prefetch-secrets.js +27 -0
- package/dist/secrets/provider-values.js +47 -0
- package/dist/secrets/secret-catalog.js +78 -0
- package/dist/secrets/secret-provider.js +19 -0
- package/dist/server/approvals-api.js +99 -0
- package/dist/server/definitions-api.js +119 -0
- package/dist/server/definitions.js +248 -0
- package/dist/server/discovery.js +9 -0
- package/dist/server/executor.js +238 -0
- package/dist/server/git-checkout.js +63 -0
- package/dist/server/http.js +39 -0
- package/dist/server/oauth.js +226 -0
- package/dist/server/policy.js +90 -0
- package/dist/server/proposals-api.js +123 -0
- package/dist/server/proposals.js +126 -0
- package/dist/server/run-request.js +67 -0
- package/dist/server/runs.js +279 -0
- package/dist/server/server-command.js +43 -0
- package/dist/server/server-config.js +233 -0
- package/dist/server/server.js +338 -0
- package/dist/server/sources-api.js +106 -0
- package/dist/server/sources.js +258 -0
- package/dist/utils/redact.js +75 -19
- package/package.json +1 -1
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.getAccessToken = exports.removeToken = exports.saveToken = exports.getCredentialsPath = void 0;
|
|
4
|
+
const crypto_1 = require("crypto");
|
|
5
|
+
const promises_1 = require("fs/promises");
|
|
6
|
+
const path_1 = require("path");
|
|
7
|
+
const get_cache_directory_1 = require("../optimizer/get-cache-directory");
|
|
8
|
+
function getCredentialsPath(environment) {
|
|
9
|
+
return environment.processEnvs.HAMMERKIT_CREDENTIALS ?? (0, path_1.join)((0, get_cache_directory_1.getHammerkitDirectory)(), 'credentials.json');
|
|
10
|
+
}
|
|
11
|
+
exports.getCredentialsPath = getCredentialsPath;
|
|
12
|
+
async function readAll(environment) {
|
|
13
|
+
const path = getCredentialsPath(environment);
|
|
14
|
+
if (!(await environment.file.exists(path))) {
|
|
15
|
+
return {};
|
|
16
|
+
}
|
|
17
|
+
try {
|
|
18
|
+
const parsed = JSON.parse(await environment.file.read(path));
|
|
19
|
+
return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : {};
|
|
20
|
+
}
|
|
21
|
+
catch {
|
|
22
|
+
// a damaged file only costs a new sign-in
|
|
23
|
+
return {};
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
// Only the owner may read it, and it is replaced atomically. The temporary file
|
|
27
|
+
// has a name nobody can guess and is created exclusively (never through a link
|
|
28
|
+
// someone left behind), already private.
|
|
29
|
+
async function writeAll(environment, all) {
|
|
30
|
+
const path = getCredentialsPath(environment);
|
|
31
|
+
await (0, promises_1.mkdir)((0, path_1.dirname)(path), { recursive: true, mode: 0o700 });
|
|
32
|
+
const temporary = `${path}.${(0, crypto_1.randomBytes)(8).toString('hex')}.tmp`;
|
|
33
|
+
const file = await (0, promises_1.open)(temporary, 'wx', 0o600);
|
|
34
|
+
try {
|
|
35
|
+
await file.writeFile(JSON.stringify(all, null, 2));
|
|
36
|
+
}
|
|
37
|
+
finally {
|
|
38
|
+
await file.close();
|
|
39
|
+
}
|
|
40
|
+
try {
|
|
41
|
+
await (0, promises_1.rename)(temporary, path);
|
|
42
|
+
}
|
|
43
|
+
catch (e) {
|
|
44
|
+
await (0, promises_1.unlink)(temporary).catch(() => undefined);
|
|
45
|
+
throw e;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
// A token that others can read is as good as published: it isn't used.
|
|
49
|
+
async function assertPrivate(environment) {
|
|
50
|
+
const path = getCredentialsPath(environment);
|
|
51
|
+
if (process.platform === 'win32' || !(await environment.file.exists(path))) {
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
const info = await (0, promises_1.stat)(path);
|
|
55
|
+
if ((info.mode & 0o077) !== 0 || (process.getuid && info.uid !== process.getuid())) {
|
|
56
|
+
throw new Error(`${path} must belong to you and be private, fix it with: chmod 600 ${path}, or sign in again`);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
async function saveToken(environment, name, token) {
|
|
60
|
+
const all = await readAll(environment);
|
|
61
|
+
all[name] = token;
|
|
62
|
+
await writeAll(environment, all);
|
|
63
|
+
}
|
|
64
|
+
exports.saveToken = saveToken;
|
|
65
|
+
async function removeToken(environment, name) {
|
|
66
|
+
const all = await readAll(environment);
|
|
67
|
+
if (!all[name]) {
|
|
68
|
+
return false;
|
|
69
|
+
}
|
|
70
|
+
delete all[name];
|
|
71
|
+
await writeAll(environment, all);
|
|
72
|
+
return true;
|
|
73
|
+
}
|
|
74
|
+
exports.removeToken = removeToken;
|
|
75
|
+
// The access token for a registered server, or the reason there is none.
|
|
76
|
+
async function getAccessToken(environment, name, server, now = Date.now()) {
|
|
77
|
+
await assertPrivate(environment);
|
|
78
|
+
const all = await readAll(environment);
|
|
79
|
+
const stored = Object.prototype.hasOwnProperty.call(all, name) ? all[name] : undefined;
|
|
80
|
+
const signIn = `sign in with: hammerkit remote login ${name}`;
|
|
81
|
+
if (!stored) {
|
|
82
|
+
throw new Error(`not signed in to ${name}, ${signIn}`);
|
|
83
|
+
}
|
|
84
|
+
if (stored.url !== server.url || stored.issuer !== server.issuer) {
|
|
85
|
+
throw new Error(`the sign-in to ${name} was made for another server, ${signIn}`);
|
|
86
|
+
}
|
|
87
|
+
if (stored.expiresAt <= now) {
|
|
88
|
+
throw new Error(`the sign-in to ${name} has expired, ${signIn}`);
|
|
89
|
+
}
|
|
90
|
+
return stored.accessToken;
|
|
91
|
+
}
|
|
92
|
+
exports.getAccessToken = getAccessToken;
|
|
93
|
+
//# sourceMappingURL=credentials.js.map
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.deviceLogin = void 0;
|
|
4
|
+
const server_client_1 = require("./server-client");
|
|
5
|
+
const GRANT_TYPE = 'urn:ietf:params:oauth:grant-type:device_code';
|
|
6
|
+
const DEFAULT_INTERVAL_SECONDS = 5;
|
|
7
|
+
const SLOW_DOWN_SECONDS = 5;
|
|
8
|
+
const FETCH_TIMEOUT_MS = 10000;
|
|
9
|
+
const MIN_INTERVAL_SECONDS = 1;
|
|
10
|
+
const MAX_LIFETIME_SECONDS = 30 * 60;
|
|
11
|
+
const isLoopbackHost = (hostname) => ['localhost', '127.0.0.1', '[::1]'].includes(hostname);
|
|
12
|
+
// An endpoint must use the scheme of the issuer: loopback http is for a test
|
|
13
|
+
// identity provider on this machine only, never beside an https issuer.
|
|
14
|
+
function isSecure(url, issuer) {
|
|
15
|
+
if (typeof url !== 'string') {
|
|
16
|
+
return false;
|
|
17
|
+
}
|
|
18
|
+
try {
|
|
19
|
+
const parsed = new URL(url);
|
|
20
|
+
const issuerHost = new URL(issuer);
|
|
21
|
+
return (parsed.protocol === 'https:' ||
|
|
22
|
+
(parsed.protocol === 'http:' && isLoopbackHost(parsed.hostname) && issuerHost.protocol === 'http:'));
|
|
23
|
+
}
|
|
24
|
+
catch {
|
|
25
|
+
return false;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
// Redirects are refused: a 307 would send the device code to another place.
|
|
29
|
+
async function fetchJson(url, init = {}) {
|
|
30
|
+
let response;
|
|
31
|
+
try {
|
|
32
|
+
response = await fetch(url, { ...init, redirect: 'error', signal: AbortSignal.timeout(FETCH_TIMEOUT_MS) });
|
|
33
|
+
}
|
|
34
|
+
catch (e) {
|
|
35
|
+
throw new server_client_1.ServerError(`unable to reach ${url}: ${(0, server_client_1.printable)(e instanceof Error ? e.message : e)}`);
|
|
36
|
+
}
|
|
37
|
+
return { status: response.status, body: await response.json().catch(() => ({})) };
|
|
38
|
+
}
|
|
39
|
+
function postForm(url, form) {
|
|
40
|
+
return fetchJson(url, {
|
|
41
|
+
method: 'POST',
|
|
42
|
+
headers: { 'content-type': 'application/x-www-form-urlencoded', accept: 'application/json' },
|
|
43
|
+
body: new URLSearchParams(form),
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
// `offline_access` asks for a refresh token, which is never kept.
|
|
47
|
+
function normalizeScope(scope) {
|
|
48
|
+
return scope
|
|
49
|
+
.split(/\s+/)
|
|
50
|
+
.filter((part) => part && part !== 'offline_access')
|
|
51
|
+
.join(' ');
|
|
52
|
+
}
|
|
53
|
+
// The token is sent to the hammerkit server next, so it has to be meant for it:
|
|
54
|
+
// a server that talked the user into a token for another service (Graph, a
|
|
55
|
+
// cloud API) gets nothing.
|
|
56
|
+
function checkToken(token, issuer, audience) {
|
|
57
|
+
let payload;
|
|
58
|
+
try {
|
|
59
|
+
payload = JSON.parse(Buffer.from(token.split('.')[1] ?? '', 'base64url').toString('utf8'));
|
|
60
|
+
}
|
|
61
|
+
catch {
|
|
62
|
+
// handled below
|
|
63
|
+
}
|
|
64
|
+
const audiences = Array.isArray(payload?.aud) ? payload.aud : [payload?.aud];
|
|
65
|
+
if (payload?.iss !== issuer || !audiences.includes(audience)) {
|
|
66
|
+
throw new server_client_1.ServerError(`the identity provider issued a token that is not meant for ${(0, server_client_1.printable)(audience)}, it is not sent to the server`);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
function abortable(sleep, signal) {
|
|
70
|
+
return async (ms) => {
|
|
71
|
+
if (signal?.aborted) {
|
|
72
|
+
throw new server_client_1.ServerError('login was cancelled');
|
|
73
|
+
}
|
|
74
|
+
await sleep(ms);
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
// OAuth device authorization grant (RFC 8628) against the issuer the server
|
|
78
|
+
// names for the issuer registered with `remote add`. Works headless: nothing is
|
|
79
|
+
// opened, the user visits the address on any device. The refresh token, if the
|
|
80
|
+
// issuer sends one, is dropped on purpose: what is kept is one short-lived
|
|
81
|
+
// access token, and signing in again is the way to renew it.
|
|
82
|
+
async function deviceLogin(server, options) {
|
|
83
|
+
const sleep = abortable(options.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms))), options.signal);
|
|
84
|
+
const now = options.now ?? Date.now;
|
|
85
|
+
const discovery = await (0, server_client_1.getDiscovery)(server);
|
|
86
|
+
const offered = discovery.issuers.find((i) => i.issuer === server.issuer);
|
|
87
|
+
if (!offered) {
|
|
88
|
+
const accepted = discovery.issuers.map((i) => (0, server_client_1.printable)(i.issuer)).join(', ');
|
|
89
|
+
throw new server_client_1.ServerError(`${server.url} does not accept tokens from ${server.issuer} (it accepts ${accepted})`);
|
|
90
|
+
}
|
|
91
|
+
if (!offered.clientId) {
|
|
92
|
+
throw new server_client_1.ServerError(`${server.url} offers no sign-in for ${server.issuer}, it needs a token obtained elsewhere`);
|
|
93
|
+
}
|
|
94
|
+
// What was pinned (by `remote add` or an earlier login) wins; the server can
|
|
95
|
+
// only offer the same, never something else to sign in with.
|
|
96
|
+
const wanted = {
|
|
97
|
+
clientId: offered.clientId,
|
|
98
|
+
audience: offered.audience,
|
|
99
|
+
scope: normalizeScope(offered.scope),
|
|
100
|
+
};
|
|
101
|
+
const pinned = {
|
|
102
|
+
clientId: server.clientId ?? wanted.clientId,
|
|
103
|
+
audience: server.audience ?? wanted.audience,
|
|
104
|
+
scope: normalizeScope(server.scope ?? wanted.scope),
|
|
105
|
+
};
|
|
106
|
+
for (const key of ['clientId', 'audience', 'scope']) {
|
|
107
|
+
if (pinned[key] !== wanted[key]) {
|
|
108
|
+
throw new server_client_1.ServerError(`${server.url} now asks for ${key} "${(0, server_client_1.printable)(wanted[key])}", but "${(0, server_client_1.printable)(pinned[key])}" is pinned. ` +
|
|
109
|
+
`If that is expected, register the server again with: hammerkit remote add <name> ${server.url} ` +
|
|
110
|
+
`--issuer ${server.issuer} --force`);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
options.prompt(`signing in to ${server.url} through ${server.issuer}\n` +
|
|
114
|
+
` client ${(0, server_client_1.printable)(pinned.clientId)}, audience ${(0, server_client_1.printable)(pinned.audience)}, scope ${(0, server_client_1.printable)(pinned.scope)}`);
|
|
115
|
+
const base = server.issuer.replace(/\/+$/, '');
|
|
116
|
+
const described = await fetchJson(`${base}/.well-known/openid-configuration`);
|
|
117
|
+
const metadata = described.body;
|
|
118
|
+
if (described.status !== 200) {
|
|
119
|
+
throw new server_client_1.ServerError(`${server.issuer} answered ${described.status} for its configuration`);
|
|
120
|
+
}
|
|
121
|
+
if (metadata.issuer !== server.issuer) {
|
|
122
|
+
throw new server_client_1.ServerError(`${server.issuer} describes itself as ${(0, server_client_1.printable)(metadata.issuer)}`);
|
|
123
|
+
}
|
|
124
|
+
if (!isSecure(metadata.device_authorization_endpoint, server.issuer) ||
|
|
125
|
+
!isSecure(metadata.token_endpoint, server.issuer)) {
|
|
126
|
+
throw new server_client_1.ServerError(`${server.issuer} does not support the device authorization grant over https`);
|
|
127
|
+
}
|
|
128
|
+
const started = await postForm(metadata.device_authorization_endpoint, {
|
|
129
|
+
client_id: pinned.clientId,
|
|
130
|
+
scope: pinned.scope,
|
|
131
|
+
audience: pinned.audience,
|
|
132
|
+
});
|
|
133
|
+
const device = started.body;
|
|
134
|
+
if (started.status !== 200 || typeof device.device_code !== 'string' || typeof device.user_code !== 'string') {
|
|
135
|
+
throw new server_client_1.ServerError(`${server.issuer} refused the sign-in: ${(0, server_client_1.printable)(device.error_description ?? device.error ?? started.status)}`);
|
|
136
|
+
}
|
|
137
|
+
const address = device.verification_uri_complete ?? device.verification_uri;
|
|
138
|
+
if (!isSecure(address, server.issuer)) {
|
|
139
|
+
throw new server_client_1.ServerError(`${server.issuer} sent a sign-in address that is not https`);
|
|
140
|
+
}
|
|
141
|
+
options.prompt(`To sign in, open ${(0, server_client_1.printable)(address)} and enter the code ${(0, server_client_1.printable)(device.user_code)}`);
|
|
142
|
+
const lifetimeSeconds = typeof device.expires_in === 'number' ? device.expires_in : 600;
|
|
143
|
+
const deadline = now() + Math.min(Math.max(lifetimeSeconds, 0), MAX_LIFETIME_SECONDS) * 1000;
|
|
144
|
+
const intervalSeconds = typeof device.interval === 'number' ? device.interval : DEFAULT_INTERVAL_SECONDS;
|
|
145
|
+
let interval = Math.max(intervalSeconds, MIN_INTERVAL_SECONDS) * 1000;
|
|
146
|
+
for (;;) {
|
|
147
|
+
await sleep(interval);
|
|
148
|
+
if (now() > deadline) {
|
|
149
|
+
throw new server_client_1.ServerError('the code expired, run the login again');
|
|
150
|
+
}
|
|
151
|
+
const answer = await postForm(metadata.token_endpoint, {
|
|
152
|
+
grant_type: GRANT_TYPE,
|
|
153
|
+
device_code: device.device_code,
|
|
154
|
+
client_id: pinned.clientId,
|
|
155
|
+
});
|
|
156
|
+
if (answer.status === 200 && typeof answer.body.access_token === 'string') {
|
|
157
|
+
checkToken(answer.body.access_token, server.issuer, pinned.audience);
|
|
158
|
+
const lifetime = typeof answer.body.expires_in === 'number' ? answer.body.expires_in : 3600;
|
|
159
|
+
return { accessToken: answer.body.access_token, expiresAt: now() + lifetime * 1000, pinned };
|
|
160
|
+
}
|
|
161
|
+
switch (answer.body.error) {
|
|
162
|
+
case 'authorization_pending':
|
|
163
|
+
break;
|
|
164
|
+
case 'slow_down':
|
|
165
|
+
interval += SLOW_DOWN_SECONDS * 1000;
|
|
166
|
+
break;
|
|
167
|
+
case 'access_denied':
|
|
168
|
+
throw new server_client_1.ServerError('the sign-in was denied');
|
|
169
|
+
case 'expired_token':
|
|
170
|
+
throw new server_client_1.ServerError('the code expired, run the login again');
|
|
171
|
+
default:
|
|
172
|
+
throw new server_client_1.ServerError(`${server.issuer} refused the sign-in: ${(0, server_client_1.printable)(answer.body.error_description ?? answer.body.error ?? answer.status)}`);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
exports.deviceLogin = deviceLogin;
|
|
177
|
+
//# sourceMappingURL=device-login.js.map
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.pinLogin = exports.setDefaultLocation = exports.removeServer = exports.addServer = exports.normalizeServerUrl = exports.validateServerName = exports.writeHostConfig = exports.readHostConfig = exports.getHostConfigPath = exports.hasServer = exports.hostConfigSchema = exports.hostServerSchema = exports.RESERVED_SERVER_NAMES = void 0;
|
|
4
|
+
const path_1 = require("path");
|
|
5
|
+
const zod_1 = require("zod");
|
|
6
|
+
const yaml_1 = require("yaml");
|
|
7
|
+
const get_cache_directory_1 = require("../optimizer/get-cache-directory");
|
|
8
|
+
// Names with a meaning of their own in `run.on` / `--on`
|
|
9
|
+
exports.RESERVED_SERVER_NAMES = ['local', 'auto'];
|
|
10
|
+
const serverNamePattern = /^[a-z0-9][a-z0-9._-]*$/i;
|
|
11
|
+
const checked = (what) => (value, ctx) => {
|
|
12
|
+
try {
|
|
13
|
+
normalizeServerUrl(value, what);
|
|
14
|
+
}
|
|
15
|
+
catch (e) {
|
|
16
|
+
ctx.addIssue({ code: zod_1.z.ZodIssueCode.custom, message: e.message });
|
|
17
|
+
}
|
|
18
|
+
};
|
|
19
|
+
// `clientId`, `audience` and `scope` are what a login asks the identity provider
|
|
20
|
+
// for. They are pinned the first time a login happens (or given to `remote add`),
|
|
21
|
+
// so a server can't change them later, see device-login.ts.
|
|
22
|
+
exports.hostServerSchema = zod_1.z
|
|
23
|
+
.object({
|
|
24
|
+
url: zod_1.z.string().superRefine(checked('url')),
|
|
25
|
+
issuer: zod_1.z.string().superRefine(checked('issuer')),
|
|
26
|
+
clientId: zod_1.z.string().min(1).optional(),
|
|
27
|
+
audience: zod_1.z.string().min(1).optional(),
|
|
28
|
+
scope: zod_1.z.string().min(1).optional(),
|
|
29
|
+
})
|
|
30
|
+
.strict();
|
|
31
|
+
// Unknown top-level keys are kept when the file is written back, so a newer
|
|
32
|
+
// hammerkit's settings survive an older one editing the file.
|
|
33
|
+
exports.hostConfigSchema = zod_1.z
|
|
34
|
+
.object({
|
|
35
|
+
servers: zod_1.z.record(exports.hostServerSchema).default({}),
|
|
36
|
+
run: zod_1.z.object({ on: zod_1.z.string().optional() }).passthrough().default({}),
|
|
37
|
+
})
|
|
38
|
+
.passthrough();
|
|
39
|
+
// Names like "constructor" must not find inherited members.
|
|
40
|
+
function hasServer(servers, name) {
|
|
41
|
+
return Object.prototype.hasOwnProperty.call(servers, name);
|
|
42
|
+
}
|
|
43
|
+
exports.hasServer = hasServer;
|
|
44
|
+
function getHostConfigPath(environment) {
|
|
45
|
+
return environment.processEnvs.HAMMERKIT_CONFIG ?? (0, path_1.join)((0, get_cache_directory_1.getHammerkitDirectory)(), 'config.yaml');
|
|
46
|
+
}
|
|
47
|
+
exports.getHostConfigPath = getHostConfigPath;
|
|
48
|
+
async function readHostConfig(environment) {
|
|
49
|
+
const path = getHostConfigPath(environment);
|
|
50
|
+
if (!(await environment.file.exists(path))) {
|
|
51
|
+
return exports.hostConfigSchema.parse({});
|
|
52
|
+
}
|
|
53
|
+
let raw;
|
|
54
|
+
try {
|
|
55
|
+
raw = (0, yaml_1.parse)(await environment.file.read(path));
|
|
56
|
+
}
|
|
57
|
+
catch (e) {
|
|
58
|
+
throw new Error(`unable to read ${path}: ${e.message}`);
|
|
59
|
+
}
|
|
60
|
+
const result = exports.hostConfigSchema.safeParse(raw ?? {});
|
|
61
|
+
if (!result.success) {
|
|
62
|
+
const issue = result.error.issues[0];
|
|
63
|
+
throw new Error(`invalid ${path}: ${issue.path.join('.') || 'config'}: ${issue.message}`);
|
|
64
|
+
}
|
|
65
|
+
return result.data;
|
|
66
|
+
}
|
|
67
|
+
exports.readHostConfig = readHostConfig;
|
|
68
|
+
async function writeHostConfig(environment, config) {
|
|
69
|
+
const path = getHostConfigPath(environment);
|
|
70
|
+
await environment.file.createDirectory((0, path_1.join)(path, '..'));
|
|
71
|
+
await environment.file.writeFile(path, (0, yaml_1.stringify)(config));
|
|
72
|
+
}
|
|
73
|
+
exports.writeHostConfig = writeHostConfig;
|
|
74
|
+
function validateServerName(name) {
|
|
75
|
+
if (exports.RESERVED_SERVER_NAMES.includes(name.toLowerCase())) {
|
|
76
|
+
throw new Error(`"${name}" is reserved and cannot be used as a server name`);
|
|
77
|
+
}
|
|
78
|
+
if (!serverNamePattern.test(name)) {
|
|
79
|
+
throw new Error(`invalid server name "${name}": use letters, digits, ".", "_" and "-", starting with a letter or digit`);
|
|
80
|
+
}
|
|
81
|
+
return name;
|
|
82
|
+
}
|
|
83
|
+
exports.validateServerName = validateServerName;
|
|
84
|
+
function isLoopback(hostname) {
|
|
85
|
+
return hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '[::1]';
|
|
86
|
+
}
|
|
87
|
+
// A server receives the source of a run and the caller's OAuth token, so it must
|
|
88
|
+
// be reached over https; plain http is only for a server on this machine. The
|
|
89
|
+
// result has no trailing slash, so one server has one spelling.
|
|
90
|
+
// An issuer is compared as a string with the `iss` of tokens, so it keeps the
|
|
91
|
+
// spelling it was given (some providers end it with a slash, some don't).
|
|
92
|
+
function normalizeServerUrl(value, what = 'url') {
|
|
93
|
+
let url;
|
|
94
|
+
try {
|
|
95
|
+
url = new URL(value);
|
|
96
|
+
}
|
|
97
|
+
catch {
|
|
98
|
+
throw new Error(`invalid ${what} "${value}"`);
|
|
99
|
+
}
|
|
100
|
+
if (url.username || url.password) {
|
|
101
|
+
throw new Error(`the ${what} must not contain credentials`);
|
|
102
|
+
}
|
|
103
|
+
if (url.search || url.hash) {
|
|
104
|
+
throw new Error(`the ${what} must not contain a query or fragment`);
|
|
105
|
+
}
|
|
106
|
+
if (url.protocol !== 'https:' && !(url.protocol === 'http:' && isLoopback(url.hostname))) {
|
|
107
|
+
throw new Error(`the ${what} must use https (http is only allowed for localhost), got "${value}"`);
|
|
108
|
+
}
|
|
109
|
+
return what === 'issuer' ? value : url.toString().replace(/\/+$/, '');
|
|
110
|
+
}
|
|
111
|
+
exports.normalizeServerUrl = normalizeServerUrl;
|
|
112
|
+
const PINNED = ['clientId', 'audience', 'scope'];
|
|
113
|
+
async function addServer(environment, name, url, options) {
|
|
114
|
+
validateServerName(name);
|
|
115
|
+
const server = {
|
|
116
|
+
url: normalizeServerUrl(url),
|
|
117
|
+
issuer: normalizeServerUrl(options.issuer, 'issuer'),
|
|
118
|
+
};
|
|
119
|
+
for (const key of PINNED) {
|
|
120
|
+
if (options[key]) {
|
|
121
|
+
server[key] = options[key];
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
const config = await readHostConfig(environment);
|
|
125
|
+
const existing = hasServer(config.servers, name) ? config.servers[name] : undefined;
|
|
126
|
+
const changed = (a, b) => a.url !== b.url || a.issuer !== b.issuer || PINNED.some((key) => a[key] !== b[key]);
|
|
127
|
+
if (existing && changed(existing, server) && !options.force) {
|
|
128
|
+
throw new Error(`server "${name}" is already registered as ${existing.url} (issuer ${existing.issuer}); ` +
|
|
129
|
+
`use --force to point it somewhere else`);
|
|
130
|
+
}
|
|
131
|
+
config.servers[name] = server;
|
|
132
|
+
await writeHostConfig(environment, config);
|
|
133
|
+
return server;
|
|
134
|
+
}
|
|
135
|
+
exports.addServer = addServer;
|
|
136
|
+
async function removeServer(environment, name) {
|
|
137
|
+
const config = await readHostConfig(environment);
|
|
138
|
+
if (!hasServer(config.servers, name)) {
|
|
139
|
+
throw new Error(`server "${name}" is not registered`);
|
|
140
|
+
}
|
|
141
|
+
delete config.servers[name];
|
|
142
|
+
const resetDefault = config.run.on === name;
|
|
143
|
+
if (resetDefault) {
|
|
144
|
+
delete config.run.on;
|
|
145
|
+
}
|
|
146
|
+
await writeHostConfig(environment, config);
|
|
147
|
+
return { resetDefault };
|
|
148
|
+
}
|
|
149
|
+
exports.removeServer = removeServer;
|
|
150
|
+
async function setDefaultLocation(environment, on) {
|
|
151
|
+
const config = await readHostConfig(environment);
|
|
152
|
+
if (!exports.RESERVED_SERVER_NAMES.includes(on) && !hasServer(config.servers, on)) {
|
|
153
|
+
throw new Error(`server "${on}" is not registered; add it with: hammerkit remote add ${on} <url> --issuer <url>`);
|
|
154
|
+
}
|
|
155
|
+
config.run.on = on;
|
|
156
|
+
await writeHostConfig(environment, config);
|
|
157
|
+
}
|
|
158
|
+
exports.setDefaultLocation = setDefaultLocation;
|
|
159
|
+
// Remember what the first login asked the identity provider for; later logins
|
|
160
|
+
// have to ask for the same.
|
|
161
|
+
async function pinLogin(environment, name, pins) {
|
|
162
|
+
const config = await readHostConfig(environment);
|
|
163
|
+
if (!hasServer(config.servers, name)) {
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
166
|
+
Object.assign(config.servers[name], pins);
|
|
167
|
+
await writeHostConfig(environment, config);
|
|
168
|
+
}
|
|
169
|
+
exports.pinLogin = pinLogin;
|
|
170
|
+
//# sourceMappingURL=host-config.js.map
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.resolveRunLocation = void 0;
|
|
4
|
+
const host_config_1 = require("./host-config");
|
|
5
|
+
// Where a run happens, highest precedence first: --on / --local, HAMMERKIT_ON,
|
|
6
|
+
// the runner of the tasks, `run.on` of the host config, local. `auto` (as
|
|
7
|
+
// --on, HAMMERKIT_ON or run.on) means the runner of the tasks, else local.
|
|
8
|
+
// A name that is not registered is an error: a run never falls back to the
|
|
9
|
+
// local machine silently.
|
|
10
|
+
async function resolveRunLocation(workTree, environment, options = {}) {
|
|
11
|
+
if (options.on && options.local) {
|
|
12
|
+
throw new Error('--on and --local contradict each other');
|
|
13
|
+
}
|
|
14
|
+
const config = await (0, host_config_1.readHostConfig)(environment);
|
|
15
|
+
const explicit = options.local ? 'local' : options.on ?? (environment.processEnvs.HAMMERKIT_ON || undefined);
|
|
16
|
+
if (explicit && explicit !== 'auto') {
|
|
17
|
+
return explicit === 'local' ? { type: 'local' } : lookup(config.servers, explicit);
|
|
18
|
+
}
|
|
19
|
+
const runners = getTaskRunners(workTree);
|
|
20
|
+
if (runners.length > 1) {
|
|
21
|
+
throw new Error(`a run goes to one server, but its tasks name ${runners.join(', ')}`);
|
|
22
|
+
}
|
|
23
|
+
const requested = runners[0] ?? (explicit ? undefined : config.run.on);
|
|
24
|
+
if (!requested || requested === 'local' || requested === 'auto') {
|
|
25
|
+
return { type: 'local' };
|
|
26
|
+
}
|
|
27
|
+
return lookup(config.servers, requested);
|
|
28
|
+
}
|
|
29
|
+
exports.resolveRunLocation = resolveRunLocation;
|
|
30
|
+
function lookup(servers, name) {
|
|
31
|
+
const server = (0, host_config_1.hasServer)(servers, name) ? servers[name] : undefined;
|
|
32
|
+
if (!server) {
|
|
33
|
+
throw new Error(`server ${name} is not registered, add it with: hammerkit remote add ${name} <url> --issuer <url>`);
|
|
34
|
+
}
|
|
35
|
+
return { type: 'server', name, server };
|
|
36
|
+
}
|
|
37
|
+
function getTaskRunners(workTree) {
|
|
38
|
+
const runners = new Set();
|
|
39
|
+
for (const task of Object.values(workTree.tasks)) {
|
|
40
|
+
if (task.data.runner) {
|
|
41
|
+
runners.add(task.data.runner);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
return [...runners].sort();
|
|
45
|
+
}
|
|
46
|
+
//# sourceMappingURL=location.js.map
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.registerRemoteCommands = void 0;
|
|
7
|
+
const commander_1 = require("commander");
|
|
8
|
+
const colors_1 = __importDefault(require("colors"));
|
|
9
|
+
const log_1 = require("../log");
|
|
10
|
+
const host_config_1 = require("./host-config");
|
|
11
|
+
const device_login_1 = require("./device-login");
|
|
12
|
+
const credentials_1 = require("./credentials");
|
|
13
|
+
const server_client_1 = require("./server-client");
|
|
14
|
+
const remote_work_commands_1 = require("./remote-work-commands");
|
|
15
|
+
// Host-level commands: they only touch the machine's config file, so they work
|
|
16
|
+
// with or without a build file in the working directory.
|
|
17
|
+
function registerRemoteCommands(program, environment, buildFile) {
|
|
18
|
+
const remote = program.command('remote').description('register the servers runs can be sent to');
|
|
19
|
+
const guarded = (failure, fn) => {
|
|
20
|
+
return async (...args) => {
|
|
21
|
+
try {
|
|
22
|
+
await fn(...args);
|
|
23
|
+
}
|
|
24
|
+
catch (e) {
|
|
25
|
+
if (e instanceof commander_1.CommanderError) {
|
|
26
|
+
throw e;
|
|
27
|
+
}
|
|
28
|
+
program.error(`${failure}: ${(0, log_1.getErrorMessage)(e)}`, { exitCode: 1 });
|
|
29
|
+
}
|
|
30
|
+
};
|
|
31
|
+
};
|
|
32
|
+
remote
|
|
33
|
+
.command('add <name> <url>')
|
|
34
|
+
.description('register a server under a name that build files and --on can refer to')
|
|
35
|
+
.addOption(new commander_1.Option('--issuer <url>', 'OAuth issuer the server accepts tokens from').makeOptionMandatory())
|
|
36
|
+
.addOption(new commander_1.Option('--client-id <id>', 'OAuth client to sign in with (pinned; default: what the server offers)'))
|
|
37
|
+
.addOption(new commander_1.Option('--audience <audience>', 'audience of the token (pinned; default: what the server offers)'))
|
|
38
|
+
.addOption(new commander_1.Option('--scope <scope>', 'scopes to request (pinned; default: what the server offers)'))
|
|
39
|
+
.addOption(new commander_1.Option('--force', 'point an already registered name at a different server').default(false))
|
|
40
|
+
.action(guarded('Remote add was not successful', async (name, url, options) => {
|
|
41
|
+
const server = await (0, host_config_1.addServer)(environment, name, url, {
|
|
42
|
+
issuer: options.issuer,
|
|
43
|
+
clientId: options.clientId,
|
|
44
|
+
audience: options.audience,
|
|
45
|
+
scope: options.scope,
|
|
46
|
+
force: options.force,
|
|
47
|
+
});
|
|
48
|
+
environment.stdout.write(`added ${name}: ${server.url} (issuer ${server.issuer})\n`);
|
|
49
|
+
}));
|
|
50
|
+
remote
|
|
51
|
+
.command('list')
|
|
52
|
+
.description('list the registered servers')
|
|
53
|
+
.action(guarded('Remote list was not successful', async () => {
|
|
54
|
+
const config = await (0, host_config_1.readHostConfig)(environment);
|
|
55
|
+
const names = Object.keys(config.servers).sort();
|
|
56
|
+
if (names.length === 0) {
|
|
57
|
+
environment.stdout.write(`no servers registered (config: ${(0, host_config_1.getHostConfigPath)(environment)})\n`);
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
for (const name of names) {
|
|
61
|
+
const server = config.servers[name];
|
|
62
|
+
const marker = config.run.on === name ? colors_1.default.green(' (default)') : '';
|
|
63
|
+
environment.stdout.write(`${colors_1.default.bold(name)}${marker} ${server.url} issuer ${server.issuer}\n`);
|
|
64
|
+
}
|
|
65
|
+
if (config.run.on === 'auto' || config.run.on === 'local') {
|
|
66
|
+
environment.stdout.write(`default location: ${config.run.on}\n`);
|
|
67
|
+
}
|
|
68
|
+
}));
|
|
69
|
+
remote
|
|
70
|
+
.command('remove <name>')
|
|
71
|
+
.description('forget a registered server')
|
|
72
|
+
.action(guarded('Remote remove was not successful', async (name) => {
|
|
73
|
+
const { resetDefault } = await (0, host_config_1.removeServer)(environment, name);
|
|
74
|
+
environment.stdout.write(`removed ${name}\n`);
|
|
75
|
+
if (resetDefault) {
|
|
76
|
+
environment.stdout.write('it was the default location, runs are local again\n');
|
|
77
|
+
}
|
|
78
|
+
}));
|
|
79
|
+
remote
|
|
80
|
+
.command('use <location>')
|
|
81
|
+
.description('set where runs happen by default: local, auto or a registered server')
|
|
82
|
+
.action(guarded('Remote use was not successful', async (location) => {
|
|
83
|
+
await (0, host_config_1.setDefaultLocation)(environment, location);
|
|
84
|
+
environment.stdout.write(`default location: ${location}\n`);
|
|
85
|
+
}));
|
|
86
|
+
const getServer = async (name) => {
|
|
87
|
+
const config = await (0, host_config_1.readHostConfig)(environment);
|
|
88
|
+
const chosen = name ?? config.run.on;
|
|
89
|
+
if (!chosen || !(0, host_config_1.hasServer)(config.servers, chosen)) {
|
|
90
|
+
throw new Error(name
|
|
91
|
+
? `server ${name} is not registered, add it with: hammerkit remote add ${name} <url> --issuer <url>`
|
|
92
|
+
: 'name a server, or set one as the default with: hammerkit remote use <name>');
|
|
93
|
+
}
|
|
94
|
+
return { name: chosen, server: config.servers[chosen] };
|
|
95
|
+
};
|
|
96
|
+
remote
|
|
97
|
+
.command('login <name>')
|
|
98
|
+
.description('sign in to a registered server with your identity provider (device code, works headless)')
|
|
99
|
+
.action(guarded('Remote login was not successful', async (name) => {
|
|
100
|
+
const { server } = await getServer(name);
|
|
101
|
+
const { pinned, ...result } = await (0, device_login_1.deviceLogin)(server, {
|
|
102
|
+
prompt: (message) => environment.stdout.write(`${message}\n`),
|
|
103
|
+
signal: environment.abortCtrl.signal,
|
|
104
|
+
});
|
|
105
|
+
await (0, host_config_1.pinLogin)(environment, name, pinned);
|
|
106
|
+
await (0, credentials_1.saveToken)(environment, name, { url: server.url, issuer: server.issuer, ...result });
|
|
107
|
+
try {
|
|
108
|
+
const me = await (0, server_client_1.getMe)(server, result.accessToken);
|
|
109
|
+
environment.stdout.write(`signed in to ${name} as ${me.sub}\n`);
|
|
110
|
+
environment.stdout.write(me.accounts.length > 0
|
|
111
|
+
? `service accounts: ${me.accounts.join(', ')}\n`
|
|
112
|
+
: 'you have no service accounts on this server\n');
|
|
113
|
+
}
|
|
114
|
+
catch (e) {
|
|
115
|
+
// a token the server does not accept is of no use
|
|
116
|
+
await (0, credentials_1.removeToken)(environment, name);
|
|
117
|
+
throw e;
|
|
118
|
+
}
|
|
119
|
+
}));
|
|
120
|
+
remote
|
|
121
|
+
.command('logout <name>')
|
|
122
|
+
.description('forget the sign-in to a server')
|
|
123
|
+
.action(guarded('Remote logout was not successful', async (name) => {
|
|
124
|
+
const removed = await (0, credentials_1.removeToken)(environment, name);
|
|
125
|
+
environment.stdout.write(removed ? `signed out of ${name}\n` : `not signed in to ${name}\n`);
|
|
126
|
+
}));
|
|
127
|
+
remote
|
|
128
|
+
.command('accounts [name]')
|
|
129
|
+
.description('show who you are on a server and which service accounts you may use')
|
|
130
|
+
.action(guarded('Remote accounts was not successful', async (name) => {
|
|
131
|
+
const chosen = await getServer(name);
|
|
132
|
+
const token = await (0, credentials_1.getAccessToken)(environment, chosen.name, chosen.server);
|
|
133
|
+
const me = await (0, server_client_1.getMe)(chosen.server, token);
|
|
134
|
+
environment.stdout.write(`${chosen.name}: ${me.sub} (${me.iss})\n`);
|
|
135
|
+
if (me.accounts.length === 0 && !(me.request && me.request.length > 0)) {
|
|
136
|
+
environment.stdout.write('no service accounts\n');
|
|
137
|
+
}
|
|
138
|
+
for (const account of me.accounts) {
|
|
139
|
+
environment.stdout.write(`${account}${account === me.default ? colors_1.default.green(' (default)') : ''}\n`);
|
|
140
|
+
}
|
|
141
|
+
if (me.publish && me.publish.length > 0) {
|
|
142
|
+
environment.stdout.write(`may publish build files: ${me.publish.join(', ')}\n`);
|
|
143
|
+
}
|
|
144
|
+
if (me.propose && me.propose.length > 0) {
|
|
145
|
+
environment.stdout.write(`may propose build files, which need an approval: ${me.propose.join(', ')}\n`);
|
|
146
|
+
}
|
|
147
|
+
if (me.request && me.request.length > 0) {
|
|
148
|
+
environment.stdout.write(`may request runs, which an admin approves: ${me.request.join(', ')}\n`);
|
|
149
|
+
}
|
|
150
|
+
}));
|
|
151
|
+
(0, remote_work_commands_1.registerWorkCommands)(remote, { environment, buildFile, getServer, guarded });
|
|
152
|
+
}
|
|
153
|
+
exports.registerRemoteCommands = registerRemoteCommands;
|
|
154
|
+
//# sourceMappingURL=remote-commands.js.map
|