hammerkit 1.27.0-next.19 → 1.27.0-next.20

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.
@@ -49,7 +49,7 @@ async function connectServer(environment, options) {
49
49
  if (!bundle_1.HASH_PATTERN.test(options.build)) {
50
50
  throw new Error(`${options.build} is not the hash of a published version (sha256:...), name it as hammerkit remote publish printed it (HAMMERKIT_BUILD or --build)`);
51
51
  }
52
- const server = config.servers[name];
52
+ const server = (0, host_config_1.getRegisteredServer)(config, name);
53
53
  return { name, connection: { url: server.url, ...(0, token_source_1.getTokenSource)(environment, name, server) }, build: options.build };
54
54
  }
55
55
  exports.connectServer = connectServer;
@@ -1,6 +1,6 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.deviceLogin = void 0;
3
+ exports.deviceLogin = exports.NoSignInError = void 0;
4
4
  const server_client_1 = require("./server-client");
5
5
  const GRANT_TYPE = 'urn:ietf:params:oauth:grant-type:device_code';
6
6
  const DEFAULT_INTERVAL_SECONDS = 5;
@@ -8,6 +8,11 @@ const SLOW_DOWN_SECONDS = 5;
8
8
  const FETCH_TIMEOUT_MS = 10000;
9
9
  const MIN_INTERVAL_SECONDS = 1;
10
10
  const MAX_LIFETIME_SECONDS = 30 * 60;
11
+ // The server takes tokens of this issuer but offers no device sign-in for it: the token has
12
+ // to come from elsewhere, such as a token command.
13
+ class NoSignInError extends server_client_1.ServerError {
14
+ }
15
+ exports.NoSignInError = NoSignInError;
11
16
  const isLoopbackHost = (hostname) => ['localhost', '127.0.0.1', '[::1]'].includes(hostname);
12
17
  // An endpoint must use the scheme of the issuer: loopback http is for a test
13
18
  // identity provider on this machine only, never beside an https issuer.
@@ -89,7 +94,7 @@ async function deviceLogin(server, options) {
89
94
  throw new server_client_1.ServerError(`${server.url} does not accept tokens from ${server.issuer} (it accepts ${accepted})`);
90
95
  }
91
96
  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`);
97
+ throw new NoSignInError(`${server.url} offers no sign-in for ${server.issuer}, it needs a token obtained elsewhere`);
93
98
  }
94
99
  // What was pinned (by `remote add` or an earlier login) wins; the server can
95
100
  // only offer the same, never something else to sign in with.
@@ -1,6 +1,6 @@
1
1
  "use strict";
2
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;
3
+ exports.pinLogin = exports.setDefaultLocation = exports.removeServer = exports.addServer = exports.normalizeServerUrl = exports.validateServerName = exports.writeHostConfig = exports.readHostConfig = exports.getHostConfigPath = exports.getRegisteredServer = exports.getTokenCommand = exports.hasServer = exports.hostConfigSchema = exports.hostServerSchema = exports.RESERVED_SERVER_NAMES = void 0;
4
4
  const path_1 = require("path");
5
5
  const zod_1 = require("zod");
6
6
  const yaml_1 = require("yaml");
@@ -28,11 +28,20 @@ exports.hostServerSchema = zod_1.z
28
28
  scope: zod_1.z.string().min(1).optional(),
29
29
  })
30
30
  .strict();
31
+ // A command that prints the token for a server instead of a login, see token-source.ts.
32
+ // It is bound to the url and issuer it was registered for, like a sign-in, and lives
33
+ // beside `servers` rather than in it: hammerkit before 1.27 refuses unknown keys of a
34
+ // server, but keeps unknown top-level keys. It is only ever read from the host config,
35
+ // a build file can't name one.
36
+ const tokenCommandSchema = zod_1.z
37
+ .object({ url: zod_1.z.string(), issuer: zod_1.z.string(), command: zod_1.z.string().trim().min(1) })
38
+ .passthrough();
31
39
  // Unknown top-level keys are kept when the file is written back, so a newer
32
40
  // hammerkit's settings survive an older one editing the file.
33
41
  exports.hostConfigSchema = zod_1.z
34
42
  .object({
35
43
  servers: zod_1.z.record(exports.hostServerSchema).default({}),
44
+ tokenCommands: zod_1.z.record(tokenCommandSchema).optional(),
36
45
  run: zod_1.z.object({ on: zod_1.z.string().optional() }).passthrough().default({}),
37
46
  })
38
47
  .passthrough();
@@ -41,6 +50,35 @@ function hasServer(servers, name) {
41
50
  return Object.prototype.hasOwnProperty.call(servers, name);
42
51
  }
43
52
  exports.hasServer = hasServer;
53
+ // The token command of a server, unless it was registered for another url or issuer (an
54
+ // older hammerkit that pointed the name elsewhere leaves it behind).
55
+ function getTokenCommand(config, name) {
56
+ const commands = config.tokenCommands ?? {};
57
+ const entry = Object.prototype.hasOwnProperty.call(commands, name) ? commands[name] : undefined;
58
+ const server = hasServer(config.servers, name) ? config.servers[name] : undefined;
59
+ return entry && server && entry.url === server.url && entry.issuer === server.issuer ? entry.command : undefined;
60
+ }
61
+ exports.getTokenCommand = getTokenCommand;
62
+ function getRegisteredServer(config, name) {
63
+ const tokenCommand = getTokenCommand(config, name);
64
+ return { ...config.servers[name], ...(tokenCommand ? { tokenCommand } : {}) };
65
+ }
66
+ exports.getRegisteredServer = getRegisteredServer;
67
+ function setTokenCommand(config, name, entry) {
68
+ const commands = { ...(config.tokenCommands ?? {}) };
69
+ if (entry) {
70
+ commands[name] = entry;
71
+ }
72
+ else {
73
+ delete commands[name];
74
+ }
75
+ if (Object.keys(commands).length > 0) {
76
+ config.tokenCommands = commands;
77
+ }
78
+ else {
79
+ delete config.tokenCommands;
80
+ }
81
+ }
44
82
  function getHostConfigPath(environment) {
45
83
  return environment.processEnvs.HAMMERKIT_CONFIG ?? (0, path_1.join)((0, get_cache_directory_1.getHammerkitDirectory)(), 'config.yaml');
46
84
  }
@@ -121,16 +159,38 @@ async function addServer(environment, name, url, options) {
121
159
  server[key] = options[key];
122
160
  }
123
161
  }
162
+ if (typeof options.tokenCommand === 'string' && !options.tokenCommand.trim()) {
163
+ throw new Error('the token command must not be empty, use --no-token-command to remove it');
164
+ }
124
165
  const config = await readHostConfig(environment);
125
166
  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]);
167
+ const sameServer = (a, b) => a.url === b.url && a.issuer === b.issuer;
168
+ // Adding the same server again keeps what was pinned (by a login, too) unless it is given,
169
+ // so a later `remote add ... --token-command` needs no --force.
170
+ if (existing && sameServer(existing, server)) {
171
+ for (const key of PINNED) {
172
+ if (!server[key] && existing[key]) {
173
+ server[key] = existing[key];
174
+ }
175
+ }
176
+ }
177
+ const changed = (a, b) => !sameServer(a, b) || PINNED.some((key) => a[key] !== b[key]);
127
178
  if (existing && changed(existing, server) && !options.force) {
128
179
  throw new Error(`server "${name}" is already registered as ${existing.url} (issuer ${existing.issuer}); ` +
129
180
  `use --force to point it somewhere else`);
130
181
  }
182
+ // The command decides which identity's token goes to the server: changing it does not
183
+ // redirect anything, but a name pointed at another server does not keep the old one.
184
+ const kept = existing && sameServer(existing, server) ? getTokenCommand(config, name) : undefined;
185
+ const tokenCommand = typeof options.tokenCommand === 'string'
186
+ ? options.tokenCommand.trim()
187
+ : options.tokenCommand === undefined
188
+ ? kept
189
+ : undefined;
131
190
  config.servers[name] = server;
191
+ setTokenCommand(config, name, tokenCommand ? { url: server.url, issuer: server.issuer, command: tokenCommand } : undefined);
132
192
  await writeHostConfig(environment, config);
133
- return server;
193
+ return { ...server, ...(tokenCommand ? { tokenCommand } : {}) };
134
194
  }
135
195
  exports.addServer = addServer;
136
196
  async function removeServer(environment, name) {
@@ -139,6 +199,7 @@ async function removeServer(environment, name) {
139
199
  throw new Error(`server "${name}" is not registered`);
140
200
  }
141
201
  delete config.servers[name];
202
+ setTokenCommand(config, name, undefined);
142
203
  const resetDefault = config.run.on === name;
143
204
  if (resetDefault) {
144
205
  delete config.run.on;
@@ -40,6 +40,8 @@ passthrough = []) {
40
40
  .addOption(new commander_1.Option('--client-id <id>', 'OAuth client to sign in with (pinned; default: what the server offers)'))
41
41
  .addOption(new commander_1.Option('--audience <audience>', 'audience of the token (pinned; default: what the server offers)'))
42
42
  .addOption(new commander_1.Option('--scope <scope>', 'scopes to request (pinned; default: what the server offers)'))
43
+ .addOption(new commander_1.Option('--token-command <command>', 'a command that prints the token for this server, instead of remote login (e.g. of a service account)'))
44
+ .addOption(new commander_1.Option('--no-token-command', 'remove the token command, sign in with remote login again'))
43
45
  .addOption(new commander_1.Option('--force', 'point an already registered name at a different server').default(false))
44
46
  .action(guarded('Remote add was not successful', async (name, url, options) => {
45
47
  const server = await (0, host_config_1.addServer)(environment, name, url, {
@@ -47,9 +49,13 @@ passthrough = []) {
47
49
  clientId: options.clientId,
48
50
  audience: options.audience,
49
51
  scope: options.scope,
52
+ tokenCommand: options.tokenCommand,
50
53
  force: options.force,
51
54
  });
52
55
  environment.stdout.write(`added ${name}: ${server.url} (issuer ${server.issuer})\n`);
56
+ if (server.tokenCommand) {
57
+ environment.stdout.write(`tokens for ${name} come from: ${server.tokenCommand}\n`);
58
+ }
53
59
  }));
54
60
  remote
55
61
  .command('list')
@@ -62,9 +68,10 @@ passthrough = []) {
62
68
  return;
63
69
  }
64
70
  for (const name of names) {
65
- const server = config.servers[name];
71
+ const server = (0, host_config_1.getRegisteredServer)(config, name);
66
72
  const marker = config.run.on === name ? colors_1.default.green(' (default)') : '';
67
- environment.stdout.write(`${colors_1.default.bold(name)}${marker} ${server.url} issuer ${server.issuer}\n`);
73
+ const token = server.tokenCommand ? ` token from: ${server.tokenCommand}` : '';
74
+ environment.stdout.write(`${colors_1.default.bold(name)}${marker} ${server.url} issuer ${server.issuer}${token}\n`);
68
75
  }
69
76
  if (config.run.on === 'auto' || config.run.on === 'local') {
70
77
  environment.stdout.write(`default location: ${config.run.on}\n`);
@@ -95,17 +102,33 @@ passthrough = []) {
95
102
  ? `server ${name} is not registered, add it with: hammerkit remote add ${name} <url> --issuer <url>`
96
103
  : 'name a server, or set one as the default with: hammerkit remote use <name>');
97
104
  }
98
- return { name: chosen, server: config.servers[chosen] };
105
+ return { name: chosen, server: (0, host_config_1.getRegisteredServer)(config, chosen) };
99
106
  };
100
107
  remote
101
108
  .command('login <name>')
102
109
  .description('sign in to a registered server with your identity provider (device code, works headless)')
103
110
  .action(guarded('Remote login was not successful', async (name) => {
104
111
  const { server } = await getServer(name);
105
- const { pinned, ...result } = await (0, device_login_1.deviceLogin)(server, {
106
- prompt: (message) => environment.stdout.write(`${message}\n`),
107
- signal: environment.abortCtrl.signal,
108
- });
112
+ if (server.tokenCommand) {
113
+ // a sign-in would never be used: the command's token goes first
114
+ throw new Error(`${name} takes its tokens from its token command (${server.tokenCommand}), it needs no sign-in; ` +
115
+ `to sign in instead, remove it with: hammerkit remote add ${name} ${server.url} --issuer ${server.issuer} --no-token-command`);
116
+ }
117
+ let login;
118
+ try {
119
+ login = await (0, device_login_1.deviceLogin)(server, {
120
+ prompt: (message) => environment.stdout.write(`${message}\n`),
121
+ signal: environment.abortCtrl.signal,
122
+ });
123
+ }
124
+ catch (e) {
125
+ if (e instanceof device_login_1.NoSignInError) {
126
+ throw new Error(`${e.message}: register a command that prints one, such as the token of a service account, with: ` +
127
+ `hammerkit remote add ${name} ${server.url} --issuer ${server.issuer} --token-command '<command>'`);
128
+ }
129
+ throw e;
130
+ }
131
+ const { pinned, ...result } = login;
109
132
  await (0, host_config_1.pinLogin)(environment, name, pinned);
110
133
  await (0, credentials_1.saveToken)(environment, name, { url: server.url, issuer: server.issuer, ...result });
111
134
  try {
@@ -9,6 +9,54 @@ function isToken(value) {
9
9
  return /^[\x21-\x7e]+$/.test(value);
10
10
  }
11
11
  exports.isToken = isToken;
12
+ // What a failed token command said on stderr, for the user's own terminal: never its stdout,
13
+ // and never in an error message, which a harness hands on to an agent.
14
+ const MAX_STDERR_LINES = 20;
15
+ const MAX_STDERR_LINE = 300;
16
+ function writeStderr(environment, what, stderr) {
17
+ const lines = stderr
18
+ .split(/\r?\n/)
19
+ .map((line) => line.replace(/[^\x20-\x7e\t]/g, '?').slice(0, MAX_STDERR_LINE))
20
+ .filter((line) => line.trim())
21
+ .slice(-MAX_STDERR_LINES);
22
+ for (const line of lines) {
23
+ environment.stderr.write(`${what}: ${line}\n`);
24
+ }
25
+ }
26
+ function commandSource(environment, command, what) {
27
+ let current;
28
+ const run = () => new Promise((resolve, reject) => {
29
+ (0, child_process_1.exec)(command, { env: environment.processEnvs, timeout: 60000 }, (error, stdout, stderr) => {
30
+ const token = String(stdout).trim();
31
+ if (error || !token) {
32
+ writeStderr(environment, what, String(stderr));
33
+ const status = error?.killed
34
+ ? ', it did not end within a minute'
35
+ : typeof error?.code === 'number'
36
+ ? `, it exited with ${error.code}`
37
+ : '';
38
+ reject(new Error(`${what} printed no token${status}`));
39
+ }
40
+ else if (!isToken(token)) {
41
+ // never the output itself: it can hold more secrets than the token
42
+ reject(new Error(`${what} printed more than a token, it has to print the token alone`));
43
+ }
44
+ else {
45
+ resolve(token);
46
+ }
47
+ });
48
+ });
49
+ return {
50
+ token: () => {
51
+ current = current ?? run();
52
+ current.catch(() => (current = undefined));
53
+ return current;
54
+ },
55
+ renew: async () => {
56
+ current = undefined;
57
+ },
58
+ };
59
+ }
12
60
  /**
13
61
  * The token a client of a server sends: a harness, and the `remote` commands. In this order:
14
62
  *
@@ -16,42 +64,29 @@ exports.isToken = isToken;
16
64
  * identity, such as a workload identity or a service account of the identity provider. It
17
65
  * runs again when the server no longer accepts the token.
18
66
  * - `HAMMERKIT_TOKEN`: such a token, as the agent's platform hands it over.
67
+ * - the token command of the server in the host config (`remote add --token-command`): the
68
+ * same as `HAMMERKIT_TOKEN_COMMAND`, for one server, so each server can have the service
69
+ * account and audience it accepts. It only ever comes from the host config, never from a
70
+ * build file: whoever writes it decides what runs on this machine.
19
71
  * - the sign-in of `hammerkit remote login <server>`.
20
72
  *
21
73
  * The token only goes to the URL the server is registered with in the host config.
22
74
  */
23
75
  function getTokenSource(environment, name, server) {
76
+ // a variable holds for every server: say so when it takes the place of the one registered
77
+ const overrides = (variable) => {
78
+ if (server.tokenCommand) {
79
+ environment.stderr.write(`${variable} is set, so the token command of ${name} is not used\n`);
80
+ }
81
+ };
24
82
  const command = environment.processEnvs.HAMMERKIT_TOKEN_COMMAND;
25
83
  if (command) {
26
- let current;
27
- const run = () => new Promise((resolve, reject) => {
28
- (0, child_process_1.exec)(command, { env: environment.processEnvs, timeout: 60000 }, (error, stdout) => {
29
- const token = String(stdout).trim();
30
- if (error || !token) {
31
- reject(new Error('HAMMERKIT_TOKEN_COMMAND printed no token'));
32
- }
33
- else if (!isToken(token)) {
34
- // never the output itself: it can hold more secrets than the token
35
- reject(new Error('HAMMERKIT_TOKEN_COMMAND printed more than a token, it has to print the token alone'));
36
- }
37
- else {
38
- resolve(token);
39
- }
40
- });
41
- });
42
- return {
43
- token: () => {
44
- current = current ?? run();
45
- current.catch(() => (current = undefined));
46
- return current;
47
- },
48
- renew: async () => {
49
- current = undefined;
50
- },
51
- };
84
+ overrides('HAMMERKIT_TOKEN_COMMAND');
85
+ return commandSource(environment, command, 'HAMMERKIT_TOKEN_COMMAND');
52
86
  }
53
87
  const fixed = environment.processEnvs.HAMMERKIT_TOKEN?.trim();
54
88
  if (fixed) {
89
+ overrides('HAMMERKIT_TOKEN');
55
90
  return {
56
91
  token: async () => {
57
92
  if (!isToken(fixed)) {
@@ -61,6 +96,9 @@ function getTokenSource(environment, name, server) {
61
96
  },
62
97
  };
63
98
  }
99
+ if (server.tokenCommand) {
100
+ return commandSource(environment, server.tokenCommand, `the token command of ${name}`);
101
+ }
64
102
  return { token: () => (0, credentials_1.getAccessToken)(environment, name, server) };
65
103
  }
66
104
  exports.getTokenSource = getTokenSource;
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "hammerkit",
3
3
  "description": "Containerized build tool with incremental caching that runs the same build file locally, in Docker and on Kubernetes.",
4
- "version": "1.27.0-next.19",
4
+ "version": "1.27.0-next.20",
5
5
  "keywords": [
6
6
  "build",
7
7
  "build-tool",