hammerkit 1.23.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.
Files changed (39) hide show
  1. package/build.schema.json +10 -0
  2. package/dist/cli.js +6 -0
  3. package/dist/executer/execute-command.js +7 -1
  4. package/dist/planner/utils/append-work-task.js +1 -0
  5. package/dist/program.js +4 -0
  6. package/dist/remote/bundle.js +59 -0
  7. package/dist/remote/collect-bundle.js +41 -0
  8. package/dist/remote/credentials.js +93 -0
  9. package/dist/remote/device-login.js +177 -0
  10. package/dist/remote/host-config.js +170 -0
  11. package/dist/remote/location.js +46 -0
  12. package/dist/remote/remote-commands.js +154 -0
  13. package/dist/remote/remote-work-commands.js +152 -0
  14. package/dist/remote/server-client.js +88 -0
  15. package/dist/remote/source-manifest.js +79 -0
  16. package/dist/remote/text-diff.js +89 -0
  17. package/dist/schema/build-file-base-task-schema.js +4 -0
  18. package/dist/schema/build-file-schema.js +3 -0
  19. package/dist/schema/reference-parser.js +1 -0
  20. package/dist/schema/runner-schema.js +13 -0
  21. package/dist/server/approvals-api.js +99 -0
  22. package/dist/server/definitions-api.js +119 -0
  23. package/dist/server/definitions.js +248 -0
  24. package/dist/server/discovery.js +9 -0
  25. package/dist/server/executor.js +238 -0
  26. package/dist/server/git-checkout.js +63 -0
  27. package/dist/server/http.js +39 -0
  28. package/dist/server/oauth.js +226 -0
  29. package/dist/server/policy.js +90 -0
  30. package/dist/server/proposals-api.js +123 -0
  31. package/dist/server/proposals.js +126 -0
  32. package/dist/server/run-request.js +67 -0
  33. package/dist/server/runs.js +279 -0
  34. package/dist/server/server-command.js +43 -0
  35. package/dist/server/server-config.js +233 -0
  36. package/dist/server/server.js +338 -0
  37. package/dist/server/sources-api.js +106 -0
  38. package/dist/server/sources.js +258 -0
  39. package/package.json +1 -1
package/build.schema.json CHANGED
@@ -261,6 +261,10 @@
261
261
  "additionalProperties": false,
262
262
  "description": "cpu and memory requests and limits"
263
263
  },
264
+ "runner": {
265
+ "type": "string",
266
+ "minLength": 1
267
+ },
264
268
  "image": {
265
269
  "type": "string"
266
270
  },
@@ -331,6 +335,9 @@
331
335
  "resources": {
332
336
  "$ref": "#/definitions/hammerkit/properties/tasks/additionalProperties/anyOf/0/properties/resources"
333
337
  },
338
+ "runner": {
339
+ "$ref": "#/definitions/hammerkit/properties/tasks/additionalProperties/anyOf/0/properties/runner"
340
+ },
334
341
  "platform": {
335
342
  "type": "object",
336
343
  "properties": {
@@ -830,6 +837,9 @@
830
837
  },
831
838
  "labels": {
832
839
  "$ref": "#/definitions/hammerkit/properties/tasks/additionalProperties/anyOf/0/properties/labels"
840
+ },
841
+ "runner": {
842
+ "$ref": "#/definitions/hammerkit/properties/tasks/additionalProperties/anyOf/0/properties/runner"
833
843
  }
834
844
  },
835
845
  "additionalProperties": false,
package/dist/cli.js CHANGED
@@ -13,6 +13,7 @@ const check_for_loop_1 = require("./executer/scheduler/check-for-loop");
13
13
  const state_1 = require("./executer/state");
14
14
  const get_scheduler_execute_result_1 = require("./executer/get-scheduler-execute-result");
15
15
  const reset_work_tree_1 = require("./executer/reset-work-tree");
16
+ const location_1 = require("./remote/location");
16
17
  const execute_work_tree_1 = require("./executer/execute-work-tree");
17
18
  const package_1 = require("./docker/package");
18
19
  const explain_1 = require("./cache/explain");
@@ -53,6 +54,11 @@ class Cli {
53
54
  await this.prepareSecrets(true);
54
55
  }
55
56
  (0, check_for_loop_1.checkForLoop)(workTree);
57
+ const location = await (0, location_1.resolveRunLocation)(workTree, this.environment);
58
+ if (location.type === 'server') {
59
+ throw new Error(`this run is meant for server ${location.name}, but running on a server is not available yet. ` +
60
+ 'Remove the runner, or run locally with: hammerkit remote use local');
61
+ }
56
62
  const capacity = (0, plan_work_tasks_1.hasError)(workTree) ? null : await (0, resource_budget_1.getResourceCapacity)(workTree, this.environment);
57
63
  if (capacity && type !== 'down' && (options?.resourceCheck ?? true)) {
58
64
  rejectOversizedWork(workTree, capacity, type === 'up');
@@ -12,11 +12,17 @@ const environment_config_1 = require("../utils/environment-config");
12
12
  function getStopTimeout() {
13
13
  return (0, environment_config_1.getEnvironmentConfig)('HAMMERKIT_STOP_TIMEOUT_MS', 10000);
14
14
  }
15
+ // powershell takes about half a minute to start when it has to work out its module
16
+ // path itself, so the one of the process is passed on
17
+ function shellEnvs(windows, environment) {
18
+ const value = environment.processEnvs['PSModulePath'];
19
+ return windows && value !== undefined ? { PSModulePath: value } : {};
20
+ }
15
21
  async function executeCommand(status, abortSignal, cwd, command, envs, environment) {
16
22
  const windows = (0, os_1.platform)() === 'win32';
17
23
  return new Promise((resolve, reject) => {
18
24
  const ps = (0, child_process_1.spawn)(command, {
19
- env: { ...envs, PATH: environment.processEnvs['PATH'] },
25
+ env: { ...envs, ...shellEnvs(windows, environment), PATH: environment.processEnvs['PATH'] },
20
26
  cwd,
21
27
  shell: windows ? 'powershell.exe' : true,
22
28
  // its own process group, so an abort reaches every process the command
@@ -72,6 +72,7 @@ function parseTask(cwd, task, environment, context) {
72
72
  timeout: task.schema.timeout ? (0, units_1.parseDuration)(task.schema.timeout) : null,
73
73
  secrets: (0, work_secret_1.parseWorkSecrets)(cwd, context.projectRoot, task.schema.secrets, envs, environment, context.secrets, task.schema.account),
74
74
  resources: (0, work_resources_1.parseWorkResources)(task.schema.resources),
75
+ runner: task.schema.runner ?? task.scope.schema.runner ?? null,
75
76
  };
76
77
  if ((0, build_file_task_schema_1.isBuildFileContainerTaskSchema)(task.schema)) {
77
78
  return {
package/dist/program.js CHANGED
@@ -25,6 +25,8 @@ const version_1 = require("./version");
25
25
  const read_only_1 = require("./cache/read-only");
26
26
  const units_1 = require("./utils/units");
27
27
  const cache_inventory_1 = require("./cache/cache-inventory");
28
+ const remote_commands_1 = require("./remote/remote-commands");
29
+ const server_command_1 = require("./server/server-command");
28
30
  async function createCli(fileName, environment, workScope) {
29
31
  const { ctx, scope } = await (0, schema_parser_1.createParseContext)(fileName, environment);
30
32
  const referencedScope = await (0, reference_parser_1.parseReferences)(ctx, scope, environment);
@@ -102,6 +104,7 @@ function parseWorkLabelScope(options) {
102
104
  }
103
105
  async function getProgram(environment, argv) {
104
106
  const program = new commander_1.Command();
107
+ (0, server_command_1.registerServerCommand)(program, environment);
105
108
  const parsed = (0, parse_set_arguments_1.parseSetArguments)(argv);
106
109
  const args = parsed.args;
107
110
  environment.processEnvs = { ...environment.processEnvs, ...parsed.values };
@@ -110,6 +113,7 @@ async function getProgram(environment, argv) {
110
113
  if (fileIndex >= 0) {
111
114
  args.splice(fileIndex, 2);
112
115
  }
116
+ (0, remote_commands_1.registerRemoteCommands)(program, environment, fileName);
113
117
  if (await environment.file.exists(fileName)) {
114
118
  program
115
119
  .command('ls')
@@ -0,0 +1,59 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.HASH_PATTERN = exports.hashBundle = exports.bundleSchema = exports.isBundlePath = exports.MAX_BUNDLE_BYTES = exports.MAX_BUNDLE_FILES = exports.DEFAULT_ENTRY = void 0;
4
+ const crypto_1 = require("crypto");
5
+ const zod_1 = require("zod");
6
+ exports.DEFAULT_ENTRY = '.hammerkit.yaml';
7
+ exports.MAX_BUNDLE_FILES = 200;
8
+ exports.MAX_BUNDLE_BYTES = 1024 * 1024;
9
+ // A path inside the bundle: relative, forward slashes, nothing that climbs out.
10
+ function isBundlePath(path) {
11
+ return (path.length > 0 &&
12
+ path.length <= 200 &&
13
+ !path.startsWith('/') &&
14
+ !path.includes('\\') &&
15
+ !path.includes('\0') &&
16
+ !path.split('/').some((part) => part === '' || part === '.' || part === '..'));
17
+ }
18
+ exports.isBundlePath = isBundlePath;
19
+ exports.bundleSchema = zod_1.z
20
+ .object({
21
+ entry: zod_1.z.string().default(exports.DEFAULT_ENTRY),
22
+ files: zod_1.z.record(zod_1.z.string()),
23
+ })
24
+ .strict()
25
+ .superRefine((bundle, ctx) => {
26
+ const paths = Object.keys(bundle.files);
27
+ const bad = paths.find((path) => !isBundlePath(path));
28
+ if (bad !== undefined) {
29
+ ctx.addIssue({
30
+ code: zod_1.z.ZodIssueCode.custom,
31
+ path: ['files'],
32
+ message: `"${bad}" is not a path inside the bundle`,
33
+ });
34
+ }
35
+ if (paths.length > exports.MAX_BUNDLE_FILES) {
36
+ ctx.addIssue({ code: zod_1.z.ZodIssueCode.custom, path: ['files'], message: `more than ${exports.MAX_BUNDLE_FILES} files` });
37
+ }
38
+ const bytes = paths.reduce((sum, path) => sum + Buffer.byteLength(bundle.files[path]), 0);
39
+ if (bytes > exports.MAX_BUNDLE_BYTES) {
40
+ ctx.addIssue({ code: zod_1.z.ZodIssueCode.custom, path: ['files'], message: `larger than ${exports.MAX_BUNDLE_BYTES} bytes` });
41
+ }
42
+ if (!isBundlePath(bundle.entry) || !Object.prototype.hasOwnProperty.call(bundle.files, bundle.entry)) {
43
+ ctx.addIssue({ code: zod_1.z.ZodIssueCode.custom, path: ['entry'], message: `${bundle.entry} is not one of the files` });
44
+ }
45
+ });
46
+ // The version of a bundle: the hash of its entry and of every file, in path order,
47
+ // so the same files always have the same version, wherever it is computed. It is
48
+ // the sha-256 of the JSON `[entry, [[path, sha-256 of the content], ...]]`.
49
+ function hashBundle(bundle) {
50
+ const files = Object.keys(bundle.files)
51
+ .sort()
52
+ .map((path) => [path, (0, crypto_1.createHash)('sha256').update(bundle.files[path]).digest('hex')]);
53
+ return `sha256:${(0, crypto_1.createHash)('sha256')
54
+ .update(JSON.stringify([bundle.entry, files]))
55
+ .digest('hex')}`;
56
+ }
57
+ exports.hashBundle = hashBundle;
58
+ exports.HASH_PATTERN = /^sha256:[0-9a-f]{64}$/;
59
+ //# sourceMappingURL=bundle.js.map
@@ -0,0 +1,41 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.collectBundle = void 0;
4
+ const path_1 = require("path");
5
+ const resolve_git_source_1 = require("../includes/resolve-git-source");
6
+ const schema_parser_1 = require("../schema/schema-parser");
7
+ const bundle_1 = require("./bundle");
8
+ // The bundle of a build file on this machine: the file itself and every local
9
+ // file it includes or references, as the server reads them when it is published.
10
+ // The build file is read with the parser of a run, so what it pulls in is what a
11
+ // run would read. Git includes are not part of it (the server fetches them, at
12
+ // the commit they are pinned to), and neither is anything outside of the
13
+ // directory of the build file, which a server could not reproduce.
14
+ async function collectBundle(fileName, environment) {
15
+ if (!(await environment.file.exists(fileName))) {
16
+ throw new Error(`there is no build file at ${fileName}`);
17
+ }
18
+ const entryPath = (0, path_1.resolve)(fileName);
19
+ const root = (0, path_1.dirname)(entryPath);
20
+ const { ctx } = await (0, schema_parser_1.createParseContext)(entryPath, environment);
21
+ const paths = new Set([entryPath]);
22
+ for (const scope of Object.values(ctx.files)) {
23
+ if (!scope.remote) {
24
+ paths.add((0, path_1.resolve)(scope.fileName));
25
+ }
26
+ }
27
+ const files = {};
28
+ for (const path of [...paths].sort()) {
29
+ if (!(0, resolve_git_source_1.isWithin)(root, path)) {
30
+ throw new Error(`${path} is outside of ${root}: a published build file can only include files below its own directory`);
31
+ }
32
+ files[(0, path_1.relative)(root, path).split(path_1.sep).join('/')] = await environment.file.read(path);
33
+ }
34
+ const result = bundle_1.bundleSchema.safeParse({ entry: (0, path_1.relative)(root, entryPath).split(path_1.sep).join('/'), files });
35
+ if (!result.success) {
36
+ throw new Error(`the build file can't be published: ${result.error.issues[0].message}`);
37
+ }
38
+ return result.data;
39
+ }
40
+ exports.collectBundle = collectBundle;
41
+ //# sourceMappingURL=collect-bundle.js.map
@@ -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