hammerkit 1.26.0 → 1.27.0-next.2

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 (118) hide show
  1. package/build.schema.json +53 -1
  2. package/dist/cache/backends/local-cache-backend.js +10 -2
  3. package/dist/cache/explain.js +13 -11
  4. package/dist/cli.js +3 -0
  5. package/dist/docker/item-network.js +471 -0
  6. package/dist/docker/pull.js +4 -2
  7. package/dist/docker/remove-orphaned-containers.js +13 -6
  8. package/dist/environment/replace-env-variables.js +8 -6
  9. package/dist/executer/await-completed-dependencies.js +2 -1
  10. package/dist/executer/check-readiness.js +2 -2
  11. package/dist/executer/docker-service.js +24 -13
  12. package/dist/executer/docker-task.js +18 -29
  13. package/dist/executer/event-cache.js +4 -0
  14. package/dist/executer/execute-command.js +18 -5
  15. package/dist/executer/execute-docker.js +4 -1
  16. package/dist/executer/execution-steps.js +33 -7
  17. package/dist/executer/get-docker-executor.js +14 -7
  18. package/dist/executer/scheduler/enqueue-next.js +17 -3
  19. package/dist/executer/watch-loop.js +2 -2
  20. package/dist/executer/watch-state-key.js +1 -1
  21. package/dist/file/within-root.js +42 -0
  22. package/dist/harness/connect.js +99 -0
  23. package/dist/harness/format.js +73 -0
  24. package/dist/harness/mcp-command.js +59 -0
  25. package/dist/harness/mcp.js +432 -0
  26. package/dist/harness/opencode.js +492 -0
  27. package/dist/harness/pi.js +217 -0
  28. package/dist/harness/sandbox-client.js +373 -0
  29. package/dist/index.js +33 -36
  30. package/dist/kubernetes/apply.js +31 -1
  31. package/dist/kubernetes/await-running-state.js +33 -1
  32. package/dist/kubernetes/ensure-kubernetes-deployment-exists.js +43 -21
  33. package/dist/kubernetes/ensure-kubernetes-persistent-volume-claim-exists.js +131 -4
  34. package/dist/kubernetes/ensure-kubernetes-service-exists.js +25 -4
  35. package/dist/kubernetes/ensure-persistent-data.js +48 -7
  36. package/dist/kubernetes/get-service-ip.js +15 -1
  37. package/dist/kubernetes/job-placement.js +99 -0
  38. package/dist/kubernetes/job-secrets.js +306 -0
  39. package/dist/kubernetes/kept-claims.js +191 -0
  40. package/dist/kubernetes/kubernetes-instance.js +10 -2
  41. package/dist/kubernetes/network-gate.js +71 -0
  42. package/dist/kubernetes/network-policy.js +526 -0
  43. package/dist/kubernetes/resources.js +13 -2
  44. package/dist/kubernetes/restore-kubernetes-data.js +19 -4
  45. package/dist/kubernetes/secrets.js +2 -0
  46. package/dist/kubernetes/store-kubernetes-data.js +1 -1
  47. package/dist/kubernetes/volumes.js +100 -11
  48. package/dist/log.js +1 -1
  49. package/dist/main.js +41 -0
  50. package/dist/network/egress-proxy.js +368 -0
  51. package/dist/network/port-relay.js +35 -0
  52. package/dist/optimizer/get-work-cache-stats.js +16 -0
  53. package/dist/optimizer/work-task-cache-description.js +3 -2
  54. package/dist/planner/service-definition.js +5 -0
  55. package/dist/planner/utils/append-work-environment.js +2 -0
  56. package/dist/planner/utils/append-work-service.js +4 -0
  57. package/dist/planner/utils/append-work-task.js +17 -1
  58. package/dist/planner/utils/assert-item-paths.js +50 -0
  59. package/dist/planner/utils/hosted-volumes.js +35 -0
  60. package/dist/planner/work-network.js +193 -0
  61. package/dist/planner/work-runtime-docker.js +41 -0
  62. package/dist/planner/work-runtime-kubernetes.js +335 -102
  63. package/dist/program.js +23 -1
  64. package/dist/remote/annotations.js +103 -0
  65. package/dist/remote/bundle-text.js +24 -0
  66. package/dist/remote/bundle.js +9 -1
  67. package/dist/remote/remote-commands.js +4 -1
  68. package/dist/remote/remote-work-commands.js +182 -69
  69. package/dist/remote/server-client.js +18 -5
  70. package/dist/remote/source-manifest.js +15 -2
  71. package/dist/remote/token-source.js +67 -0
  72. package/dist/schema/build-file-base-task-schema.js +3 -0
  73. package/dist/schema/build-file-container-service-schema.js +6 -0
  74. package/dist/schema/build-file-container-task-schema.js +3 -0
  75. package/dist/schema/build-file-environment-schema.js +9 -0
  76. package/dist/schema/network-schema.js +78 -0
  77. package/dist/schema/reference-parser.js +1 -0
  78. package/dist/schema/schema-parser.js +16 -3
  79. package/dist/schema/work-scope-parser.js +5 -0
  80. package/dist/secrets/command-secret-provider.js +73 -10
  81. package/dist/secrets/github-app-token-command.js +26 -0
  82. package/dist/secrets/github-app-token.js +191 -0
  83. package/dist/server/accounts-api.js +9 -2
  84. package/dist/server/definitions-api.js +69 -47
  85. package/dist/server/definitions.js +76 -51
  86. package/dist/server/executor.js +156 -41
  87. package/dist/server/git-checkout.js +72 -8
  88. package/dist/server/kubernetes-backend.js +562 -0
  89. package/dist/server/kubernetes-sandbox-storage.js +996 -0
  90. package/dist/server/oauth.js +1 -0
  91. package/dist/server/plan.js +51 -1
  92. package/dist/server/policy.js +104 -15
  93. package/dist/server/proposals-api.js +24 -40
  94. package/dist/server/proposals.js +12 -25
  95. package/dist/server/repo-access.js +37 -6
  96. package/dist/server/run-logs.js +217 -0
  97. package/dist/server/run-plan.js +51 -2
  98. package/dist/server/run-request.js +23 -10
  99. package/dist/server/run-store.js +5 -0
  100. package/dist/server/run-worker-command.js +13 -1
  101. package/dist/server/run-worker.js +121 -24
  102. package/dist/server/runs.js +184 -10
  103. package/dist/server/sandbox-docker.js +62 -0
  104. package/dist/server/sandbox-files-api.js +269 -0
  105. package/dist/server/sandbox-files.js +1054 -0
  106. package/dist/server/sandbox-patch.js +481 -0
  107. package/dist/server/sandbox-pod-command.js +15 -0
  108. package/dist/server/sandbox-pod.js +146 -0
  109. package/dist/server/sandbox-storage.js +462 -0
  110. package/dist/server/sandboxes-api.js +210 -0
  111. package/dist/server/sandboxes.js +519 -0
  112. package/dist/server/server-config.js +412 -22
  113. package/dist/server/server.js +204 -39
  114. package/dist/utils/run-id.js +12 -0
  115. package/harness/opencode.ts +11 -0
  116. package/harness/pi.ts +7 -0
  117. package/package.json +8 -2
  118. package/dist/remote/text-diff.js +0 -89
@@ -0,0 +1,103 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.applyAnnotations = exports.collectAnnotations = exports.remoteRepo = void 0;
4
+ const child_process_1 = require("child_process");
5
+ const os_1 = require("os");
6
+ const path_1 = require("path");
7
+ const util_1 = require("util");
8
+ const repo_1 = require("../server/repo");
9
+ const bundle_1 = require("./bundle");
10
+ const execFileAsync = (0, util_1.promisify)(child_process_1.execFile);
11
+ async function git(cwd, ...args) {
12
+ try {
13
+ const value = (await execFileAsync('git', args, { cwd, timeout: 5000 })).stdout.trim();
14
+ return value || undefined;
15
+ }
16
+ catch {
17
+ return undefined;
18
+ }
19
+ }
20
+ // `host/path` of a remote, or nothing: a url that is neither https, ssh nor
21
+ // `user@host:path` is left out, so a credential in it never leaves the machine.
22
+ function remoteRepo(url) {
23
+ const scp = /^[\w.-]+@([\w.-]+):(?!\/)(.+)$/.exec(url);
24
+ if (scp) {
25
+ return (0, repo_1.normalizeRepo)(`${scp[1]}/${scp[2]}`) ?? undefined;
26
+ }
27
+ try {
28
+ const parsed = new URL(url);
29
+ if (!['https:', 'ssh:'].includes(parsed.protocol)) {
30
+ return undefined;
31
+ }
32
+ return (0, repo_1.normalizeRepo)(`${parsed.hostname}${parsed.pathname}`) ?? undefined;
33
+ }
34
+ catch {
35
+ return undefined;
36
+ }
37
+ }
38
+ exports.remoteRepo = remoteRepo;
39
+ // Where a build file is published from: its path in its repository (or on this
40
+ // machine, outside of one), the repository and commit, this machine and its user.
41
+ // `files` are the paths of the bundle, relative to the build file's directory: when
42
+ // one of them differs from the commit, `dirty=true` says the version is not that
43
+ // commit's. What can not be found is left out.
44
+ async function collectAnnotations(fileName, files = [(0, path_1.basename)(fileName)]) {
45
+ const path = (0, path_1.resolve)(fileName);
46
+ const cwd = (0, path_1.dirname)(path);
47
+ const annotations = {};
48
+ // git says where the directory is in the repository: comparing paths would trip on
49
+ // what the file system calls the same directory differently (symlinks, 8.3 names)
50
+ const inside = (await git(cwd, 'rev-parse', '--is-inside-work-tree')) === 'true';
51
+ const prefix = inside ? (await git(cwd, 'rev-parse', '--show-prefix')) ?? '' : undefined;
52
+ annotations.path = prefix !== undefined ? `${prefix}${(0, path_1.basename)(path)}` : path;
53
+ if (inside) {
54
+ const remote = await git(cwd, 'remote', 'get-url', 'origin');
55
+ const repo = remote ? remoteRepo(remote) : undefined;
56
+ const commit = await git(cwd, 'rev-parse', 'HEAD');
57
+ if (repo) {
58
+ annotations.repo = repo;
59
+ }
60
+ if (commit) {
61
+ annotations.commit = commit;
62
+ const changed = await git(cwd, '--literal-pathspecs', 'status', '--porcelain', '--untracked-files=all', '--', ...files);
63
+ if (changed) {
64
+ annotations.dirty = 'true';
65
+ }
66
+ }
67
+ }
68
+ annotations.machine = (0, os_1.hostname)();
69
+ try {
70
+ annotations.user = (0, os_1.userInfo)().username;
71
+ }
72
+ catch {
73
+ // a user without an entry in the password database
74
+ }
75
+ return Object.fromEntries(Object.entries(annotations).filter(([, value]) => value));
76
+ }
77
+ exports.collectAnnotations = collectAnnotations;
78
+ // `--annotation key=value` sets one, `key=` leaves it out.
79
+ function applyAnnotations(annotations, options) {
80
+ const result = { ...annotations };
81
+ for (const option of options) {
82
+ const index = option.indexOf('=');
83
+ if (index <= 0) {
84
+ throw new Error(`${option} is not an annotation, write it as key=value`);
85
+ }
86
+ const key = option.slice(0, index);
87
+ const value = option.slice(index + 1);
88
+ if (value) {
89
+ result[key] = value;
90
+ }
91
+ else {
92
+ delete result[key];
93
+ }
94
+ }
95
+ const parsed = bundle_1.annotationsSchema.safeParse(result);
96
+ if (!parsed.success) {
97
+ const issue = parsed.error.issues[0];
98
+ throw new Error(`${issue.path.length > 0 ? `annotation ${issue.path.join('.')}` : 'annotations'}: ${issue.message}`);
99
+ }
100
+ return parsed.data;
101
+ }
102
+ exports.applyAnnotations = applyAnnotations;
103
+ //# sourceMappingURL=annotations.js.map
@@ -0,0 +1,24 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.showBundle = exports.visible = void 0;
4
+ // Text from a proposal is shown to somebody who decides on it, so nothing in it may
5
+ // rewrite the terminal or hide what it says: control and format characters (which
6
+ // include the ones that reorder text) are shown as `?`. Long lines are not cut,
7
+ // what is hidden behind a cut is what a reviewer needs to see.
8
+ function visible(line) {
9
+ return line.replace(/\t/g, ' ').replace(/[\p{Cc}\p{Cf}\p{Zl}\p{Zp}]/gu, '?');
10
+ }
11
+ exports.visible = visible;
12
+ const lines = (text) => (text === '' ? [] : text.replace(/\n$/, '').split('\n'));
13
+ // Every file of a version, whole: a version has no other one it could be read
14
+ // against, whoever decides on it reads all of it.
15
+ function showBundle(bundle) {
16
+ return Object.keys(bundle.files)
17
+ .sort()
18
+ .flatMap((path) => [
19
+ `--- ${visible(path)}${path === bundle.entry ? ' (entry)' : ''}`,
20
+ ...lines(bundle.files[path]).map((line) => ` ${visible(line)}`),
21
+ ]);
22
+ }
23
+ exports.showBundle = showBundle;
24
+ //# sourceMappingURL=bundle-text.js.map
@@ -1,6 +1,6 @@
1
1
  "use strict";
2
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;
3
+ exports.HASH_PATTERN = exports.hashBundle = exports.bundleSchema = exports.annotationsSchema = exports.MAX_ANNOTATIONS = exports.isBundlePath = exports.MAX_BUNDLE_BYTES = exports.MAX_BUNDLE_FILES = exports.DEFAULT_ENTRY = void 0;
4
4
  const crypto_1 = require("crypto");
5
5
  const zod_1 = require("zod");
6
6
  exports.DEFAULT_ENTRY = '.hammerkit.yaml';
@@ -16,6 +16,14 @@ function isBundlePath(path) {
16
16
  !path.split('/').some((part) => part === '' || part === '.' || part === '..'));
17
17
  }
18
18
  exports.isBundlePath = isBundlePath;
19
+ exports.MAX_ANNOTATIONS = 20;
20
+ exports.annotationsSchema = zod_1.z
21
+ .record(zod_1.z
22
+ .string()
23
+ .regex(/^[a-z][a-z0-9._-]{0,63}$/, 'use lower case letters, digits, ".", "_" and "-", starting with a letter'), zod_1.z.string().min(1).max(500))
24
+ .refine((annotations) => Object.keys(annotations).length <= exports.MAX_ANNOTATIONS, {
25
+ message: `at most ${exports.MAX_ANNOTATIONS} annotations`,
26
+ });
19
27
  exports.bundleSchema = zod_1.z
20
28
  .object({
21
29
  entry: zod_1.z.string().default(exports.DEFAULT_ENTRY),
@@ -10,8 +10,10 @@ const log_1 = require("../log");
10
10
  const host_config_1 = require("./host-config");
11
11
  const device_login_1 = require("./device-login");
12
12
  const credentials_1 = require("./credentials");
13
+ const token_source_1 = require("./token-source");
13
14
  const server_client_1 = require("./server-client");
14
15
  const remote_work_commands_1 = require("./remote-work-commands");
16
+ const mcp_command_1 = require("../harness/mcp-command");
15
17
  // Host-level commands: they only touch the machine's config file, so they work
16
18
  // with or without a build file in the working directory.
17
19
  function registerRemoteCommands(program, environment, buildFile) {
@@ -129,7 +131,7 @@ function registerRemoteCommands(program, environment, buildFile) {
129
131
  .description('show who you are on a server and which service accounts you may use')
130
132
  .action(guarded('Remote accounts was not successful', async (name) => {
131
133
  const chosen = await getServer(name);
132
- const token = await (0, credentials_1.getAccessToken)(environment, chosen.name, chosen.server);
134
+ const token = await (0, token_source_1.getTokenSource)(environment, chosen.name, chosen.server).token();
133
135
  const me = await (0, server_client_1.getMe)(chosen.server, token);
134
136
  const { body } = await (0, server_client_1.callServer)(chosen.server, token, 'GET', '/v1/accounts');
135
137
  environment.stdout.write(`${chosen.name}: ${(0, server_client_1.printable)(me.sub)} (${(0, server_client_1.printable)(me.iss)})\n`);
@@ -153,6 +155,7 @@ function registerRemoteCommands(program, environment, buildFile) {
153
155
  }
154
156
  }));
155
157
  (0, remote_work_commands_1.registerWorkCommands)(remote, { environment, buildFile, getServer, guarded });
158
+ (0, mcp_command_1.registerMcpCommand)(remote, environment);
156
159
  }
157
160
  exports.registerRemoteCommands = registerRemoteCommands;
158
161
  //# sourceMappingURL=remote-commands.js.map
@@ -3,32 +3,54 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
3
3
  return (mod && mod.__esModule) ? mod : { "default": mod };
4
4
  };
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
- exports.registerWorkCommands = void 0;
6
+ exports.registerWorkCommands = exports.describePlan = exports.describeApproval = void 0;
7
7
  const commander_1 = require("commander");
8
8
  const colors_1 = __importDefault(require("colors"));
9
- const credentials_1 = require("./credentials");
9
+ const token_source_1 = require("./token-source");
10
10
  const collect_bundle_1 = require("./collect-bundle");
11
11
  const bundle_1 = require("./bundle");
12
+ const annotations_1 = require("./annotations");
12
13
  const server_client_1 = require("./server-client");
13
- const text_diff_1 = require("./text-diff");
14
- const PROPOSAL_NAME = /^([a-z0-9][a-z0-9._-]{0,63})@(sha256:[0-9a-f]{64})$/i;
15
- // A proposed version is named like `payments@sha256:...`
16
- function parseProposal(value) {
17
- const match = PROPOSAL_NAME.exec(value);
18
- return match && bundle_1.HASH_PATTERN.test(match[2]) ? { name: match[1], hash: match[2] } : undefined;
19
- }
14
+ const bundle_text_1 = require("./bundle-text");
20
15
  const RUN_ID = /^run_[A-Za-z0-9_-]{16}$/;
16
+ // what the publisher said about where a version comes from, one line
17
+ // What a publisher said about where a version comes from: the server takes it as said,
18
+ // so anybody else is told it is not checked.
19
+ function annotationLines(annotations, label = 'from (unverified)') {
20
+ const entries = Object.entries(annotations ?? {});
21
+ return entries.length > 0
22
+ ? [` ${label}: ${entries.map(([key, value]) => `${(0, server_client_1.printable)(key)}=${(0, server_client_1.printable)(value)}`).join(' ')}`]
23
+ : [];
24
+ }
25
+ function yankLines(version) {
26
+ return version.yanked
27
+ ? [
28
+ ` yanked by ${(0, server_client_1.printable)(version.yanked.by.sub)}${version.yanked.reason ? `: ${(0, server_client_1.printable)(version.yanked.reason)}` : ''}`,
29
+ ]
30
+ : [];
31
+ }
32
+ const collectOption = (value, previous = []) => [...previous, value];
21
33
  // What a person who approves is shown of a run: everything the decision rests on.
22
- function describe(run) {
23
- const source = run.source.type === 'ref' ? `ref ${run.source.ref}` : `snapshot ${run.source.snapshot}`;
24
- const options = Object.entries(run.options)
34
+ function describeApproval(run) {
35
+ const source = run.source.type === 'ref'
36
+ ? `ref ${run.source.ref}`
37
+ : run.source.type === 'sandbox'
38
+ ? `sandbox of ${run.source.repo} at ${run.source.commit}, ${run.source.clean ? 'unchanged' : `with changes, files ${run.source.tree}`} (files .gitignore leaves out are not shown)`
39
+ : `snapshot ${run.source.snapshot}`;
40
+ // the arguments are shown whole on a line of their own, each as a JSON string,
41
+ // so commas, spaces and quotes inside one stay visible
42
+ const { args, ...settings } = run.options;
43
+ const options = Object.entries(settings)
25
44
  .map(([key, value]) => `${key}=${Array.isArray(value) ? value.join(',') : value}`)
26
45
  .join(' ');
27
46
  return [
28
47
  `${colors_1.default.bold((0, server_client_1.printable)(run.id))} requested by ${(0, server_client_1.printable)(run.requestedBy.sub)} (${(0, server_client_1.printable)(run.requestedBy.iss)})`,
29
- ` ${(0, server_client_1.printable)(run.definition.name)} ${(0, server_client_1.printable)(run.definition.hash)}`,
48
+ ` build file ${(0, server_client_1.printable)(run.definition)}`,
30
49
  ` tasks: ${run.tasks.length > 0 ? run.tasks.map(server_client_1.printable).join(', ') : 'selected by labels'}`,
31
50
  ` accounts: ${run.accounts.length > 0 ? run.accounts.map(server_client_1.printable).join(', ') : 'none'}, source ${(0, server_client_1.printable)(source)}${options ? `, ${(0, server_client_1.printable)(options)}` : ''}`,
51
+ ...(Array.isArray(args) && args.length > 0
52
+ ? [` arguments: ${args.map((arg) => (0, server_client_1.printableWhole)(JSON.stringify(arg))).join(' ')}`]
53
+ : []),
32
54
  ...(run.approvals ?? []).map((approval) => ` ${(0, server_client_1.printable)(approval.account)}: ${approval.approvedBy
33
55
  ? `approved by ${(0, server_client_1.printable)(approval.approvedBy.sub)}`
34
56
  : 'waits for somebody who uses it to approve'}`),
@@ -36,6 +58,7 @@ function describe(run) {
36
58
  ` decide before ${(0, server_client_1.printable)(run.approvals?.[0]?.expiresAt ?? '-')}`,
37
59
  ];
38
60
  }
61
+ exports.describeApproval = describeApproval;
39
62
  // What to look at first, one line.
40
63
  function flagLines(run) {
41
64
  const flagged = Object.entries(run.plan ?? {}).filter(([, entry]) => entry.flags.length > 0);
@@ -51,7 +74,11 @@ function flagLines(run) {
51
74
  function describePlan(plan) {
52
75
  return Object.entries(plan).flatMap(([name, entry]) => [
53
76
  ` ${colors_1.default.bold((0, server_client_1.printable)(name))} ${entry.kind}, ${(0, server_client_1.printable)(entry.runtime)}${entry.image ? ` ${(0, server_client_1.printable)(entry.image)}` : ''}${entry.flags.length > 0 ? ` [${entry.flags.map(server_client_1.printable).join(', ')}]` : ''}`,
54
- ...entry.cmds.map((cmd) => ` $ ${(0, server_client_1.printable)(cmd)}`),
77
+ // a command is what is decided on: shown whole, however long
78
+ ...entry.cmds.map((cmd) => ` $ ${(0, server_client_1.printableWhole)(cmd)}`),
79
+ ...(entry.network !== undefined && entry.network !== 'open'
80
+ ? [` network: ${(0, server_client_1.printable)(typeof entry.network === 'string' ? entry.network : JSON.stringify(entry.network))}`]
81
+ : []),
55
82
  ...(entry.deps.length > 0 ? [` after: ${entry.deps.map(server_client_1.printable).join(', ')}`] : []),
56
83
  ...(entry.needs.length > 0 ? [` needs: ${entry.needs.map(server_client_1.printable).join(', ')}`] : []),
57
84
  ...entry.secrets.map((secret) => ` secret ${(0, server_client_1.printable)(secret.from)}${secret.account ? ` as ${(0, server_client_1.printable)(secret.account)}` : ''} -> ${(0, server_client_1.printable)(secret.target)}`),
@@ -60,29 +87,78 @@ function describePlan(plan) {
60
87
  ...(entry.generates.length > 0 ? [` generates: ${entry.generates.map(server_client_1.printable).join(', ')}`] : []),
61
88
  ]);
62
89
  }
90
+ exports.describePlan = describePlan;
63
91
  function registerWorkCommands(remote, context) {
64
92
  const { environment, buildFile, getServer, guarded } = context;
65
93
  const connect = async (name) => {
66
94
  const chosen = await getServer(name);
67
- const token = await (0, credentials_1.getAccessToken)(environment, chosen.name, chosen.server);
95
+ // a CI job or an agent brings its own token, a person signs in
96
+ const token = await (0, token_source_1.getTokenSource)(environment, chosen.name, chosen.server).token();
68
97
  return { ...chosen, token };
69
98
  };
70
99
  remote
71
- .command('publish <definition> [server]')
72
- .description('publish the build file and the files it includes to a server, under a name runs can refer to')
73
- .action(guarded('Remote publish was not successful', async (definition, name) => {
100
+ .command('publish [server]')
101
+ .description('publish the build file and the files it includes to a server, runs refer to it by its hash')
102
+ .addOption(new commander_1.Option('--annotation <key=value>', 'say more about where it comes from, or leave one out with key=; path, repo, commit, machine and user are filled in').argParser(collectOption))
103
+ .action(guarded('Remote publish was not successful', async (name, options) => {
74
104
  const bundle = await (0, collect_bundle_1.collectBundle)(buildFile, environment);
105
+ const annotations = (0, annotations_1.applyAnnotations)(await (0, annotations_1.collectAnnotations)(buildFile, Object.keys(bundle.files)), options.annotation ?? []);
75
106
  const { name: serverName, server, token } = await connect(name);
76
- const answer = await (0, server_client_1.callServer)(server, token, 'PUT', `/v1/definitions/${encodeURIComponent(definition)}`, bundle);
107
+ const answer = await (0, server_client_1.callServer)(server, token, 'POST', '/v1/definitions', {
108
+ ...bundle,
109
+ annotations,
110
+ });
77
111
  const version = answer.body;
78
112
  const files = Object.keys(bundle.files).length;
79
113
  const proposed = answer.status === 202;
80
114
  const verb = proposed ? 'proposed' : answer.status === 201 ? 'published' : 'already published';
81
- environment.stdout.write(`${verb} ${definition} to ${serverName}: ${(0, server_client_1.printable)(version.hash)} (${files} ${files === 1 ? 'file' : 'files'})\n`);
115
+ environment.stdout.write(`${verb} to ${serverName}: ${(0, server_client_1.printable)(version.hash)} (${files} ${files === 1 ? 'file' : 'files'})\n`);
82
116
  const names = [...(version.tasks ?? []), ...(version.services ?? [])];
83
117
  environment.stdout.write(`tasks and services: ${names.map(server_client_1.printable).join(', ') || 'none'}\n`);
118
+ for (const line of annotationLines(version.annotations, 'from')) {
119
+ environment.stdout.write(`${line.trimStart()}\n`);
120
+ }
84
121
  if (proposed) {
85
- environment.stdout.write(`it can not run before somebody who may publish ${(0, server_client_1.printable)(definition)} approves it, until ${(0, server_client_1.printable)(version.expiresAt)}\n`);
122
+ environment.stdout.write(`it can not run before somebody who may publish approves it, until ${(0, server_client_1.printable)(version.expiresAt)}\n`);
123
+ }
124
+ }));
125
+ remote
126
+ .command('versions [server]')
127
+ .description('list the published versions of build files on a server, newest first, with where they come from')
128
+ .addOption(new commander_1.Option('--filter <key=value>', 'only the versions with this annotation, such as repo=github.com/corp/payments').argParser(collectOption))
129
+ .action(guarded('Remote versions was not successful', async (name, options) => {
130
+ const query = new URLSearchParams();
131
+ for (const filter of (options.filter ?? [])) {
132
+ const index = filter.indexOf('=');
133
+ if (index <= 0) {
134
+ throw new Error(`${(0, server_client_1.printable)(filter)} is not a filter, write it as key=value`);
135
+ }
136
+ if (index === filter.length - 1) {
137
+ throw new Error(`${(0, server_client_1.printable)(filter)} matches nothing, an annotation always has a value`);
138
+ }
139
+ query.append(filter.slice(0, index), filter.slice(index + 1));
140
+ }
141
+ const { name: serverName, server, token } = await connect(name);
142
+ const path = `/v1/definitions${query.toString() !== '' ? `?${query}` : ''}`;
143
+ const { body } = await (0, server_client_1.callServer)(server, token, 'GET', path);
144
+ if (body.definitions.length === 0) {
145
+ environment.stdout.write(`${serverName}: no published versions${query.toString() !== '' ? ' match' : ''}\n`);
146
+ return;
147
+ }
148
+ environment.stdout.write(`${serverName}: ${body.definitions.length} published\n`);
149
+ for (const version of body.definitions) {
150
+ environment.stdout.write(`${colors_1.default.bold((0, server_client_1.printable)(version.hash))} published by ${(0, server_client_1.printable)(version.publishedBy.sub)} at ${(0, server_client_1.printable)(version.publishedAt)}${version.approvedBy ? `, approved by ${(0, server_client_1.printable)(version.approvedBy.sub)}` : ''}\n`);
151
+ for (const line of annotationLines(version.annotations)) {
152
+ environment.stdout.write(`${line}\n`);
153
+ }
154
+ const names = [...version.tasks, ...version.services];
155
+ environment.stdout.write(` tasks and services: ${names.map(server_client_1.printable).join(', ') || 'none'}\n`);
156
+ if (version.accounts.length > 0) {
157
+ environment.stdout.write(` accounts: ${version.accounts.map(server_client_1.printable).join(', ')}\n`);
158
+ }
159
+ for (const line of yankLines(version)) {
160
+ environment.stdout.write(`${line}\n`);
161
+ }
86
162
  }
87
163
  }));
88
164
  remote
@@ -97,21 +173,84 @@ function registerWorkCommands(remote, context) {
97
173
  }
98
174
  environment.stdout.write(`${serverName}: ${body.approvals.length} waiting for your approval\n`);
99
175
  for (const run of body.approvals) {
100
- for (const line of describe(run)) {
176
+ for (const line of describeApproval(run)) {
101
177
  environment.stdout.write(`${line}\n`);
102
178
  }
103
179
  }
104
180
  }));
181
+ // A version that is published: who published it, what its tasks do and every file.
182
+ const showPublished = async (hash, serverName, server, token) => {
183
+ const path = `/v1/definitions/${hash}`;
184
+ let version;
185
+ try {
186
+ version = (await (0, server_client_1.callServer)(server, token, 'GET', path)).body;
187
+ }
188
+ catch (e) {
189
+ if (e instanceof server_client_1.ServerError && e.status === 404) {
190
+ throw new Error(`${serverName} has no proposal or published version ${(0, server_client_1.printable)(hash)} you may see`);
191
+ }
192
+ throw e;
193
+ }
194
+ const files = (await (0, server_client_1.callServer)(server, token, 'GET', `${path}/files`)).body;
195
+ environment.stdout.write(`${colors_1.default.bold((0, server_client_1.printable)(version.hash))} on ${serverName}, published by ${(0, server_client_1.printable)(version.publishedBy.sub)} (${(0, server_client_1.printable)(version.publishedBy.iss)}) at ${(0, server_client_1.printable)(version.publishedAt)}${version.approvedBy ? `, approved by ${(0, server_client_1.printable)(version.approvedBy.sub)}` : ''}\n`);
196
+ for (const line of [
197
+ ...yankLines(version),
198
+ ...annotationLines(version.annotations),
199
+ ...flagLines(version),
200
+ ...(version.plan ? describePlan(version.plan) : [' the server did not record what its tasks do']),
201
+ ...(0, bundle_text_1.showBundle)(files),
202
+ ]) {
203
+ environment.stdout.write(`${line}\n`);
204
+ }
205
+ };
105
206
  remote
106
- .command('show <run> [server]')
107
- .description('show a run that waits for your approval in full: the commands, secrets and accounts of its tasks')
108
- .action(guarded('Remote show was not successful', async (run, name) => {
109
- if (!RUN_ID.test(run)) {
110
- throw new Error(`${(0, server_client_1.printable)(run)} is not a run id, list them with: hammerkit remote approvals`);
207
+ .command('show <target> [server]')
208
+ .description('show a run (run_...) or a proposed or published version (sha256:...) in full: the commands, secrets and accounts of its tasks, and the files of a version')
209
+ .action(guarded('Remote show was not successful', async (target, name) => {
210
+ if (!RUN_ID.test(target) && !bundle_1.HASH_PATTERN.test(target)) {
211
+ throw new Error(`${(0, server_client_1.printable)(target)} is not a run id or a version, list them with: hammerkit remote approvals, remote proposals or remote versions`);
111
212
  }
112
- const { server, token } = await connect(name);
113
- const view = (await (0, server_client_1.callServer)(server, token, 'GET', `/v1/approvals/${run}`)).body;
114
- for (const line of describe(view)) {
213
+ const { name: serverName, server, token } = await connect(name);
214
+ if (bundle_1.HASH_PATTERN.test(target)) {
215
+ const path = `/v1/proposals/${target}`;
216
+ let proposal;
217
+ try {
218
+ proposal = (await (0, server_client_1.callServer)(server, token, 'GET', path)).body;
219
+ }
220
+ catch (e) {
221
+ if (e instanceof server_client_1.ServerError && e.status === 404) {
222
+ await showPublished(target, serverName, server, token);
223
+ return;
224
+ }
225
+ throw e;
226
+ }
227
+ if (proposal.state === 'approved') {
228
+ await showPublished(target, serverName, server, token);
229
+ return;
230
+ }
231
+ if (proposal.state !== 'waiting') {
232
+ // a decided proposal keeps no files
233
+ environment.stdout.write(`${colors_1.default.bold((0, server_client_1.printable)(proposal.hash))} on ${serverName}, proposed by ${(0, server_client_1.printable)(proposal.proposedBy.sub)}, ${(0, server_client_1.printable)(proposal.state)}${proposal.decidedBy ? ` by ${(0, server_client_1.printable)(proposal.decidedBy.sub)}` : ''}${proposal.reason ? `: ${(0, server_client_1.printable)(proposal.reason)}` : ''}\n`);
234
+ for (const line of annotationLines(proposal.annotations)) {
235
+ environment.stdout.write(`${line}\n`);
236
+ }
237
+ environment.stdout.write(' its files are not kept once it is decided\n');
238
+ return;
239
+ }
240
+ const files = (await (0, server_client_1.callServer)(server, token, 'GET', `${path}/files`)).body;
241
+ environment.stdout.write(`${colors_1.default.bold((0, server_client_1.printable)(proposal.hash))} on ${serverName}, proposed by ${(0, server_client_1.printable)(proposal.proposedBy.sub)} (${(0, server_client_1.printable)(proposal.proposedBy.iss)}), ${(0, server_client_1.printable)(proposal.state)}\n`);
242
+ for (const line of [
243
+ ...annotationLines(proposal.annotations),
244
+ ...flagLines(proposal),
245
+ ...describePlan(proposal.plan ?? {}),
246
+ ...(0, bundle_text_1.showBundle)(files),
247
+ ]) {
248
+ environment.stdout.write(`${line}\n`);
249
+ }
250
+ return;
251
+ }
252
+ const view = (await (0, server_client_1.callServer)(server, token, 'GET', `/v1/approvals/${target}`)).body;
253
+ for (const line of describeApproval(view)) {
115
254
  environment.stdout.write(`${line}\n`);
116
255
  }
117
256
  environment.stdout.write(view.plan ? `${describePlan(view.plan).join('\n')}\n` : 'the server did not record what its tasks do\n');
@@ -130,7 +269,10 @@ function registerWorkCommands(remote, context) {
130
269
  for (const proposal of body.proposals) {
131
270
  const who = proposal.mine ? 'you' : (0, server_client_1.printable)(proposal.proposedBy.sub);
132
271
  const state = proposal.canDecide ? colors_1.default.bold('waits for your approval') : (0, server_client_1.printable)(proposal.state);
133
- environment.stdout.write(`${colors_1.default.bold(`${(0, server_client_1.printable)(proposal.name)}@${(0, server_client_1.printable)(proposal.hash)}`)} proposed by ${who}, ${state}\n`);
272
+ environment.stdout.write(`${colors_1.default.bold((0, server_client_1.printable)(proposal.hash))} proposed by ${who}, ${state}\n`);
273
+ for (const line of annotationLines(proposal.annotations)) {
274
+ environment.stdout.write(`${line}\n`);
275
+ }
134
276
  const names = [...proposal.tasks, ...proposal.services];
135
277
  environment.stdout.write(` tasks and services: ${names.map(server_client_1.printable).join(', ') || 'none'}\n`);
136
278
  if (proposal.accounts.length > 0) {
@@ -141,9 +283,6 @@ function registerWorkCommands(remote, context) {
141
283
  for (const line of flagLines(proposal)) {
142
284
  environment.stdout.write(`${line}\n`);
143
285
  }
144
- if (proposal.stale) {
145
- environment.stdout.write(` stale: ${(0, server_client_1.printable)(proposal.name)} has a newer version, reject it and propose again on top of it\n`);
146
- }
147
286
  if (proposal.state === 'waiting') {
148
287
  environment.stdout.write(` decide before ${(0, server_client_1.printable)(proposal.expiresAt)}\n`);
149
288
  }
@@ -152,43 +291,19 @@ function registerWorkCommands(remote, context) {
152
291
  }
153
292
  }
154
293
  }));
155
- remote
156
- .command('diff <proposal> [server]')
157
- .description('show what a proposed version (name@hash) changes against the version it follows')
158
- .action(guarded('Remote diff was not successful', async (target, name) => {
159
- const proposal = parseProposal(target);
160
- if (!proposal) {
161
- throw new Error(`${(0, server_client_1.printable)(target)} is not a proposal, list them with: hammerkit remote proposals`);
162
- }
163
- const { name: serverName, server, token } = await connect(name);
164
- const path = `/v1/proposals/${encodeURIComponent(proposal.name)}/${proposal.hash}`;
165
- const view = (await (0, server_client_1.callServer)(server, token, 'GET', path)).body;
166
- const proposed = (await (0, server_client_1.callServer)(server, token, 'GET', `${path}/files`)).body;
167
- const base = view.base
168
- ? (await (0, server_client_1.callServer)(server, token, 'GET', `/v1/definitions/${encodeURIComponent(proposal.name)}/${view.base}/files`)).body
169
- : undefined;
170
- environment.stdout.write(`${(0, server_client_1.printable)(proposal.name)}@${(0, server_client_1.printable)(proposal.hash)} on ${serverName}, proposed by ${(0, server_client_1.printable)(view.proposedBy.sub)} (${(0, server_client_1.printable)(view.proposedBy.iss)})\n`);
171
- environment.stdout.write(base ? `compared with ${(0, server_client_1.printable)(view.base)}\n` : 'no version is published yet, everything is new\n');
172
- if (view.stale) {
173
- environment.stdout.write(`warning: ${(0, server_client_1.printable)(proposal.name)} has a newer version (${(0, server_client_1.printable)(view.latest)}), this can only be rejected\n`);
174
- }
175
- const lines = (0, text_diff_1.diffBundles)(base, proposed);
176
- environment.stdout.write(lines.length > 0 ? `${lines.join('\n')}\n` : 'the files are the same\n');
177
- }));
178
294
  for (const action of ['yank', 'unyank']) {
179
295
  remote
180
296
  .command(`${action} <version> [server]`)
181
297
  .description(action === 'yank'
182
- ? 'pull a published version (name@hash) back: no run starts from it and it is not latest'
183
- : 'let a yanked version (name@hash) go on')
298
+ ? 'pull a published version (sha256:...) back: no run starts from it'
299
+ : 'let a yanked version (sha256:...) go on')
184
300
  .addOption(new commander_1.Option('--reason <text>', 'why, shown to whoever tries to run it').argParser((value) => value))
185
301
  .action(guarded(`Remote ${action} was not successful`, async (target, name, options) => {
186
- const version = parseProposal(target);
187
- if (!version) {
188
- throw new Error(`${(0, server_client_1.printable)(target)} is not a version, name it like payments@sha256:...`);
302
+ if (!bundle_1.HASH_PATTERN.test(target)) {
303
+ throw new Error(`${(0, server_client_1.printable)(target)} is not a version, name it by its hash: sha256:...`);
189
304
  }
190
305
  const { name: serverName, server, token } = await connect(name);
191
- await (0, server_client_1.callServer)(server, token, 'POST', `/v1/definitions/${encodeURIComponent(version.name)}/${version.hash}/${action}`, options.reason ? { reason: options.reason } : undefined);
306
+ await (0, server_client_1.callServer)(server, token, 'POST', `/v1/definitions/${target}/${action}`, options.reason ? { reason: options.reason } : undefined);
192
307
  environment.stdout.write(`${action === 'yank' ? 'yanked' : 'let go on'} ${(0, server_client_1.printable)(target)} on ${serverName}\n`);
193
308
  }));
194
309
  }
@@ -196,18 +311,16 @@ function registerWorkCommands(remote, context) {
196
311
  remote
197
312
  .command(`${action} <target> [server]`)
198
313
  .description(action === 'approve'
199
- ? 'let a run (run_...) or a proposed version (name@hash) that waits for your approval go ahead'
200
- : 'end a run (run_...) or drop a proposed version (name@hash) that waits for your approval')
314
+ ? 'let a run (run_...) or a proposed version (sha256:...) that waits for your approval go ahead'
315
+ : 'end a run (run_...) or drop a proposed version (sha256:...) that waits for your approval')
201
316
  .addOption(new commander_1.Option('--reason <text>', 'why, shown to who asked').argParser((value) => value))
202
317
  .action(guarded(`Remote ${action} was not successful`, async (target, name, options) => {
203
- const proposal = parseProposal(target);
318
+ const proposal = bundle_1.HASH_PATTERN.test(target);
204
319
  if (!proposal && !RUN_ID.test(target)) {
205
320
  throw new Error(`${(0, server_client_1.printable)(target)} is not a run id or a proposed version, list them with: hammerkit remote approvals or remote proposals`);
206
321
  }
207
322
  const { name: serverName, server, token } = await connect(name);
208
- const path = proposal
209
- ? `/v1/proposals/${encodeURIComponent(proposal.name)}/${proposal.hash}/${action}`
210
- : `/v1/approvals/${target}/${action}`;
323
+ const path = proposal ? `/v1/proposals/${target}/${action}` : `/v1/approvals/${target}/${action}`;
211
324
  const { body } = await (0, server_client_1.callServer)(server, token, 'POST', path, options.reason ? { reason: options.reason } : undefined);
212
325
  const rejected = body.state === 'rejected';
213
326
  environment.stdout.write(proposal
@@ -1,20 +1,33 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.callServer = exports.getMe = exports.getDiscovery = exports.printable = exports.ServerError = void 0;
3
+ exports.callServer = exports.getMe = exports.getDiscovery = exports.printableWhole = exports.printable = exports.ServerError = void 0;
4
4
  const discovery_1 = require("../server/discovery");
5
5
  const TIMEOUT_MS = 10000;
6
6
  const WRITE_TIMEOUT_MS = 30000;
7
7
  class ServerError extends Error {
8
+ // what the server answered, when it did
9
+ constructor(message, status) {
10
+ super(message);
11
+ this.status = status;
12
+ }
8
13
  }
9
14
  exports.ServerError = ServerError;
10
15
  // Text from the server or the identity provider is shown to the user: no
11
- // control characters, so it can't rewrite the terminal.
16
+ // control characters, so it can't rewrite the terminal, and format characters
17
+ // (bidi overrides, zero-width) written out, so text reads in the order it runs.
18
+ // Long text is cut, and says how much was.
12
19
  function printable(value) {
20
+ const text = printableWhole(value);
21
+ return text.length > 300 ? `${text.slice(0, 300)}… (+${text.length - 300} chars)` : text;
22
+ }
23
+ exports.printable = printable;
24
+ // The same, never cut: for what somebody decides on, such as a command.
25
+ function printableWhole(value) {
13
26
  return String(value)
14
27
  .replace(/\p{Cc}/gu, '?')
15
- .slice(0, 300);
28
+ .replace(/\p{Cf}/gu, (char) => `\\u{${char.codePointAt(0)?.toString(16)}}`);
16
29
  }
17
- exports.printable = printable;
30
+ exports.printableWhole = printableWhole;
18
31
  async function getJson(url, token) {
19
32
  let response;
20
33
  try {
@@ -80,7 +93,7 @@ async function callServer(server, token, method, path, body) {
80
93
  }
81
94
  if (!response.ok) {
82
95
  const said = answer && typeof answer.error === 'string' ? `: ${printable(answer.error)}` : '';
83
- throw new ServerError(`${server.url} answered ${response.status}${said}`);
96
+ throw new ServerError(`${server.url} answered ${response.status}${said}`, response.status);
84
97
  }
85
98
  return { status: response.status, body: answer };
86
99
  }
@@ -53,6 +53,16 @@ function hashSource(manifest) {
53
53
  }
54
54
  exports.hashSource = hashSource;
55
55
  const DENIED_DIRECTORIES = ['.git', '.ssh', '.aws', '.gnupg', '.kube'];
56
+ // the short names Windows gives these directories, `GIT~1` for `.git`
57
+ const DENIED_SHORT_NAMES = /^(git|ssh|aws|gnupg|kube)~\d+$/;
58
+ // A name as a case-insensitive disk opens it: Windows and macOS open `.SSH` as `.ssh`, and
59
+ // Windows drops trailing dots and spaces (`.git.`) and reads `name:stream` as `name`.
60
+ function canonicalName(name) {
61
+ return name
62
+ .replace(/:.*$/, '')
63
+ .replace(/[. ]+$/, '')
64
+ .toLowerCase();
65
+ }
56
66
  const DENIED_NAMES = [
57
67
  /^\.env$/i,
58
68
  /^\.env\.(?!example$|sample$|template$|dist$)/i,
@@ -65,12 +75,15 @@ const DENIED_NAMES = [
65
75
  // must not be what decides that a key leaves the machine.
66
76
  function deniedSourcePath(path) {
67
77
  const parts = path.split('/');
68
- const directory = parts.slice(0, -1).find((part) => DENIED_DIRECTORIES.includes(part));
78
+ const directory = parts.slice(0, -1).find((part) => {
79
+ const name = canonicalName(part);
80
+ return DENIED_DIRECTORIES.includes(name) || DENIED_SHORT_NAMES.test(name);
81
+ });
69
82
  if (directory !== undefined) {
70
83
  return `${directory}/ holds credentials or history`;
71
84
  }
72
85
  const name = parts[parts.length - 1];
73
- if (DENIED_NAMES.some((pattern) => pattern.test(name))) {
86
+ if (DENIED_NAMES.some((pattern) => pattern.test(name) || pattern.test(canonicalName(name)))) {
74
87
  return `${name} looks like a credential file`;
75
88
  }
76
89
  return null;