hammerkit 1.25.0 → 1.26.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.
@@ -3,12 +3,14 @@ Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.removeContainerTaskState = exports.writeContainerTaskState = exports.readContainerTaskState = exports.getContainerTaskStateFile = void 0;
4
4
  const path_1 = require("path");
5
5
  const get_cache_directory_1 = require("../optimizer/get-cache-directory");
6
- const work_instance_id_1 = require("../planner/work-instance-id");
6
+ const work_state_record_1 = require("../planner/work-state-record");
7
7
  // The record says "the outputs of this container task belong to this state
8
- // key", like the state file of a local task. It lives in the hammerkit
9
- // directory, not the project, so writing it never touches a task's src.
8
+ // key", like the state file of a local task (see work-state-record.ts). It lives
9
+ // in the hammerkit directory, not the project, so writing it never touches a
10
+ // task's src; the record name folds in the project root, so two checkouts keep
11
+ // their own.
10
12
  function getContainerTaskStateFile(task) {
11
- return (0, path_1.join)((0, get_cache_directory_1.getHammerkitDirectory)(), 'state', (0, work_instance_id_1.getWorkInstanceId)(task));
13
+ return (0, path_1.join)((0, get_cache_directory_1.getHammerkitDirectory)(), 'state', (0, work_state_record_1.getStateRecordName)(task));
12
14
  }
13
15
  exports.getContainerTaskStateFile = getContainerTaskStateFile;
14
16
  async function readContainerTaskState(environment, task) {
@@ -16,12 +18,12 @@ async function readContainerTaskState(environment, task) {
16
18
  if (!(await environment.file.exists(stateFile))) {
17
19
  return null;
18
20
  }
19
- return (await environment.file.read(stateFile)).trim() || null;
21
+ return (0, work_state_record_1.parseStateRecord)(task, await environment.file.read(stateFile));
20
22
  }
21
23
  exports.readContainerTaskState = readContainerTaskState;
22
24
  async function writeContainerTaskState(environment, task, stateKey) {
23
25
  await environment.file.createDirectory((0, path_1.join)((0, get_cache_directory_1.getHammerkitDirectory)(), 'state'));
24
- await environment.file.writeFile(getContainerTaskStateFile(task), stateKey);
26
+ await environment.file.writeFile(getContainerTaskStateFile(task), (0, work_state_record_1.formatStateRecord)(task, stateKey));
25
27
  }
26
28
  exports.writeContainerTaskState = writeContainerTaskState;
27
29
  async function removeContainerTaskState(environment, task) {
@@ -30,7 +30,7 @@ async function ensureKubernetesDeploymentExists(instance, env, service, persiste
30
30
  const envs = (0, replace_env_variables_1.getEnvironmentVariables)(service.data.envs);
31
31
  const name = (0, resources_1.getResourceName)(service);
32
32
  const healthcheckCommand = (0, work_service_1.getHealthcheckCommand)(service.data);
33
- const serviceCommand = (0, work_service_1.getServiceCommand)(service.data);
33
+ const serviceCommand = (0, work_service_1.getServiceCommand)(service.data, { kubernetes: true });
34
34
  const probe = healthcheckCommand
35
35
  ? {
36
36
  exec: {
@@ -4,6 +4,7 @@ exports.getServiceDefinitionHash = void 0;
4
4
  const crypto_1 = require("crypto");
5
5
  const work_secret_1 = require("./work-secret");
6
6
  const work_service_1 = require("./work-service");
7
+ const work_cache_id_1 = require("./work-cache-id");
7
8
  // Everything a running container of the service was created from, plus the
8
9
  // definitions of the services it needs: a dependent links to the container it
9
10
  // started with, so a recreated need means a recreated dependent too. Stored as
@@ -30,8 +31,8 @@ function getServiceDefinitionHash(item) {
30
31
  ? `provider:${(0, work_secret_1.getSecretName)(s)}:${(0, work_secret_1.describeSecretSource)(s)}`
31
32
  : `${s.source.type}:${(0, work_secret_1.getSecretName)(s)}`),
32
33
  // the init task's id covers its image, commands, envs and mounts: a changed
33
- // init recreates the service, so it runs again
34
- init: service.init ? service.init.task.id() : null,
34
+ // init recreates the service, so it runs again (arguments after -- don't)
35
+ init: service.init ? (0, work_cache_id_1.getDeclaredWorkTaskId)(service.init.task) : null,
35
36
  needs: item.needs
36
37
  .filter((need) => (0, work_service_1.isContainerWorkService)(need.service.data))
37
38
  .map((need) => `${need.name}=${getServiceDefinitionHash(need.service)}`),
@@ -0,0 +1,155 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.isRefusedByWindowsPowerShell = exports.applyArguments = exports.getArgumentsTarget = exports.getKubernetesCommandArgs = exports.getKubernetesCommandText = exports.escapeKubernetesText = exports.appendArguments = exports.quoteArgument = exports.getShellQuoting = void 0;
4
+ const os_1 = require("os");
5
+ const POSIX_SHELLS = ['sh', 'bash', 'zsh', 'dash', 'ash', 'ksh', 'mksh'];
6
+ const POWERSHELLS = ['powershell', 'pwsh'];
7
+ // The quoting of a shell, by the name of its program; null for a shell whose
8
+ // quoting isn't known (fish, cmd.exe, …). Guessing would let shell syntax in an
9
+ // argument run as a second command, so such a shell takes no arguments.
10
+ function getShellQuoting(shell) {
11
+ const program = shell.trim().split(/\s+/)[0] ?? '';
12
+ const name = (program.split(/[\\/]/).pop() ?? '').toLowerCase().replace(/\.exe$/, '');
13
+ if (POSIX_SHELLS.includes(name)) {
14
+ return 'posix';
15
+ }
16
+ if (POWERSHELLS.includes(name)) {
17
+ return 'powershell';
18
+ }
19
+ return null;
20
+ }
21
+ exports.getShellQuoting = getShellQuoting;
22
+ // Single quotes take everything literally in both shells. In sh a quote ends the
23
+ // string, so it is closed, escaped and reopened; PowerShell doubles it, and reads
24
+ // the typographic single quotes as quotes too.
25
+ function quoteArgument(arg, quoting) {
26
+ if (quoting === 'powershell') {
27
+ return `'${arg.replace(/['‘’‚‛]/g, (quote) => quote + quote)}'`;
28
+ }
29
+ return `'${arg.replace(/'/g, `'\\''`)}'`;
30
+ }
31
+ exports.quoteArgument = quoteArgument;
32
+ function appendArguments(command, args, quoting) {
33
+ // an exec-form command is shown as sh would read it; it runs from `parsed`
34
+ const display = quoting === 'exec' ? 'posix' : quoting;
35
+ return {
36
+ cwd: command.cwd,
37
+ cmd: [command.cmd, ...args.map((arg) => quoteArgument(arg, display))].join(' '),
38
+ parsed: {
39
+ command: command.parsed.command,
40
+ args: [...command.parsed.args, ...args],
41
+ },
42
+ arguments: { cmd: command.cmd.length, args: command.parsed.args.length },
43
+ };
44
+ }
45
+ exports.appendArguments = appendArguments;
46
+ // Kubernetes expands `$(NAME)` with the container's env and turns `$$` into `$`
47
+ // in a container's command and args, before any shell sees them, so a quoted
48
+ // argument isn't text there unless its `$` is doubled. The part of the command
49
+ // the build file declares keeps its meaning.
50
+ function escapeKubernetesText(text) {
51
+ return text.replace(/\$/g, '$$$$');
52
+ }
53
+ exports.escapeKubernetesText = escapeKubernetesText;
54
+ function getKubernetesCommandText(command) {
55
+ if (!command.arguments) {
56
+ return command.cmd;
57
+ }
58
+ const from = command.arguments.cmd;
59
+ return command.cmd.slice(0, from) + escapeKubernetesText(command.cmd.slice(from));
60
+ }
61
+ exports.getKubernetesCommandText = getKubernetesCommandText;
62
+ function getKubernetesCommandArgs(command) {
63
+ const from = command.arguments?.args ?? command.parsed.args.length;
64
+ return command.parsed.args.map((arg, index) => (index >= from ? escapeKubernetesText(arg) : arg));
65
+ }
66
+ exports.getKubernetesCommandArgs = getKubernetesCommandArgs;
67
+ // The command arguments after `--` land in: the last command of a task, the
68
+ // `cmd` of a container service. Null when there is none.
69
+ function getArgumentsTarget(item) {
70
+ if (item.type === 'local-task' || item.type === 'container-task') {
71
+ return item.cmds[item.cmds.length - 1] ?? null;
72
+ }
73
+ if (item.type === 'container-service') {
74
+ return item.cmd;
75
+ }
76
+ return null;
77
+ }
78
+ exports.getArgumentsTarget = getArgumentsTarget;
79
+ // Appends the arguments to the one task or service a run selects, before
80
+ // anything asks for its id: the last command is part of the task id, so the
81
+ // arguments are part of its cache key. A run with arguments is a one-off (one
82
+ // test, one flag), so the task skips the cache unless `keepCache`; even then the
83
+ // key keeps runs with other arguments apart. Dependencies and needed services
84
+ // are planned items of their own and keep their commands and caching. No
85
+ // arguments change nothing, so a task run without them keeps its cache entries.
86
+ function applyArguments(selection, args, keepCache = false) {
87
+ if (args.length === 0) {
88
+ return;
89
+ }
90
+ const selected = [...selection.tasks, ...selection.services];
91
+ if (selected.length === 0) {
92
+ return;
93
+ }
94
+ if (selected.length > 1) {
95
+ throw new Error(`arguments after -- go to one task or service, but this run selects ${selected.map((s) => s.name).join(', ')}`);
96
+ }
97
+ if (args.some((arg) => arg.includes('\0'))) {
98
+ throw new Error('an argument after -- contains a NUL character');
99
+ }
100
+ const item = selected[0];
101
+ if (item.type === 'kubernetes-service') {
102
+ throw new Error(`service ${item.name} is a kubernetes service and has no cmd for the arguments after --`);
103
+ }
104
+ const target = getArgumentsTarget(item);
105
+ if (!target) {
106
+ throw new Error(item.type === 'container-service'
107
+ ? `service ${item.name} has no cmd for the arguments after --`
108
+ : `task ${item.name} has no command for the arguments after --`);
109
+ }
110
+ const quoting = getItemQuoting(item);
111
+ if (item.type === 'local-task' && quoting === 'powershell') {
112
+ const refused = args.find(isRefusedByWindowsPowerShell);
113
+ if (refused !== undefined) {
114
+ throw new Error(`task ${item.name} runs in Windows PowerShell, which would split or run the argument ${JSON.stringify(refused)}, so it is refused: an empty argument, a ", the characters & | < > ^ % !, line breaks, and a space with a trailing \\`);
115
+ }
116
+ }
117
+ if (item.type === 'container-service') {
118
+ item.cmd = appendArguments(target, args, quoting);
119
+ }
120
+ else {
121
+ item.cmds = [...item.cmds.slice(0, -1), appendArguments(target, args, quoting)];
122
+ if (!keepCache) {
123
+ // not implicit, so the run's --cache default doesn't bring the method back
124
+ item.caching = { ...item.caching, method: 'none', implicit: false };
125
+ }
126
+ }
127
+ }
128
+ exports.applyArguments = applyArguments;
129
+ // Windows PowerShell 5.1 builds a new command line for a native program, and
130
+ // starts a .cmd or .bat shim (yarn.cmd, most of node_modules/.bin) through
131
+ // cmd.exe. There an empty argument is dropped, a " splits or joins arguments,
132
+ // an argument with a space that ends in \ is quoted as "a b\" and so escapes
133
+ // its own closing quote, and cmd reads & | < > ^ % ! and a line break as
134
+ // syntax, so such an argument can't be passed as text.
135
+ function isRefusedByWindowsPowerShell(arg) {
136
+ return arg === '' || /["&|<>^%!\r\n]/.test(arg) || (/\s/.test(arg) && arg.endsWith('\\'));
137
+ }
138
+ exports.isRefusedByWindowsPowerShell = isRefusedByWindowsPowerShell;
139
+ function getItemQuoting(item) {
140
+ if (item.type === 'local-task') {
141
+ // what executeCommand runs a local command in
142
+ return (0, os_1.platform)() === 'win32' ? 'powershell' : 'posix';
143
+ }
144
+ const shell = item.type === 'kubernetes-service' ? null : item.shell;
145
+ if (shell === null) {
146
+ return 'exec';
147
+ }
148
+ const quoting = getShellQuoting(shell);
149
+ if (!quoting) {
150
+ throw new Error(`${item.type === 'container-task' ? 'task' : 'service'} ${item.name} runs in ${shell}, whose quoting hammerkit ` +
151
+ `doesn't know, so it takes no arguments after --; use sh, bash, zsh, dash, ash, ksh, mksh or PowerShell`);
152
+ }
153
+ return quoting;
154
+ }
155
+ //# sourceMappingURL=task-arguments.js.map
@@ -1,6 +1,6 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.getWorkTaskId = void 0;
3
+ exports.getDeclaredWorkTaskId = exports.getWorkTaskId = void 0;
4
4
  const crypto_1 = require("crypto");
5
5
  const work_task_cache_description_1 = require("../optimizer/work-task-cache-description");
6
6
  function getWorkTaskId(item) {
@@ -9,4 +9,18 @@ function getWorkTaskId(item) {
9
9
  return (0, crypto_1.createHash)('sha1').update(jsonData).digest('hex');
10
10
  }
11
11
  exports.getWorkTaskId = getWorkTaskId;
12
+ // The id of the task as the build file declares it: without the arguments
13
+ // after -- a run appended to its last command. A service's definition takes its
14
+ // init task by this id, so `run <init> -- ...` leaves a running service as it is.
15
+ function getDeclaredWorkTaskId(item) {
16
+ const description = (0, work_task_cache_description_1.getWorkTaskCacheDescription)(item);
17
+ const cmds = description.cmds?.map((cmd, index) => {
18
+ const declared = item.data.cmds[index];
19
+ return declared.arguments ? { ...cmd, cmd: declared.cmd.slice(0, declared.arguments.cmd) } : cmd;
20
+ });
21
+ return (0, crypto_1.createHash)('sha1')
22
+ .update(JSON.stringify({ ...description, cmds }))
23
+ .digest('hex');
24
+ }
25
+ exports.getDeclaredWorkTaskId = getDeclaredWorkTaskId;
12
26
  //# sourceMappingURL=work-cache-id.js.map
@@ -39,6 +39,8 @@ function dockerTaskRuntime(task, workEnvironment) {
39
39
  });
40
40
  },
41
41
  async restore(environment, path) {
42
+ // the restored outputs aren't the ones the record names any more
43
+ await (0, container_task_state_1.removeContainerTaskState)(environment, task);
42
44
  await restoreContainer(docker, environment, task, path);
43
45
  },
44
46
  async archive(environment, path) {
@@ -22,6 +22,7 @@ const ensure_http_route_1 = require("../kubernetes/ensure-http-route");
22
22
  const find_process_1 = __importDefault(require("find-process"));
23
23
  const log_1 = require("../log");
24
24
  const version_1 = require("../version");
25
+ const task_arguments_1 = require("./utils/task-arguments");
25
26
  const restore_kubernetes_data_1 = require("../kubernetes/restore-kubernetes-data");
26
27
  const store_kubernetes_data_1 = require("../kubernetes/store-kubernetes-data");
27
28
  const remove_persistent_data_1 = require("../kubernetes/remove-persistent-data");
@@ -110,7 +111,7 @@ function kubernetesTaskRuntime(task, kubernetes) {
110
111
  image: task.data.image,
111
112
  // through the task's shell, as on Docker
112
113
  command: [task.data.shell, '-c'],
113
- args: [cmd.cmd],
114
+ args: [(0, task_arguments_1.getKubernetesCommandText)(cmd)],
114
115
  workingDir: cmd.cwd,
115
116
  env: [
116
117
  ...Object.entries(envs).map(([key, value]) => ({ name: key, value })),
@@ -3,7 +3,7 @@ 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.getLocalWorkRuntime = void 0;
6
+ exports.getLocalWorkRuntime = exports.getStateFilename = void 0;
7
7
  const tar_1 = require("tar");
8
8
  const path_1 = require("path");
9
9
  const local_task_1 = require("../executer/local-task");
@@ -11,9 +11,11 @@ const event_cache_1 = require("../executer/event-cache");
11
11
  const get_outputs_to_reset_1 = require("./utils/get-outputs-to-reset");
12
12
  const find_process_1 = __importDefault(require("find-process"));
13
13
  const stream_1 = require("stream");
14
+ const work_state_record_1 = require("./work-state-record");
14
15
  function getStateFilename(task) {
15
- return (0, path_1.join)(task.data.cwd, '.hammerkit', `${task.id()}`);
16
+ return (0, path_1.join)(task.data.cwd, '.hammerkit', 'state', (0, work_state_record_1.getStateRecordName)(task));
16
17
  }
18
+ exports.getStateFilename = getStateFilename;
17
19
  function getPidFilename(task) {
18
20
  return (0, path_1.join)(task.data.cwd, '.hammerkit', `${task.id()}.pid`);
19
21
  }
@@ -39,6 +41,8 @@ function getLocalWorkRuntime(task) {
39
41
  // pidfile check happens in execute (initialize has no Environment in scope)
40
42
  },
41
43
  async restore(environment, path) {
44
+ // the restored outputs aren't the ones the record names any more
45
+ await removeStateFile(environment, task);
42
46
  await restoreLocal(environment, task.data, path);
43
47
  },
44
48
  async archive(environment, path) {
@@ -54,7 +58,7 @@ function getLocalWorkRuntime(task) {
54
58
  });
55
59
  return;
56
60
  }
57
- await environment.file.createDirectory((0, path_1.join)(task.data.cwd, '.hammerkit'));
61
+ await environment.file.createDirectory((0, path_1.join)(task.data.cwd, '.hammerkit', 'state'));
58
62
  const pidFile = getPidFilename(task);
59
63
  await environment.file.writeFile(pidFile, `${process.pid}`);
60
64
  try {
@@ -62,16 +66,14 @@ function getLocalWorkRuntime(task) {
62
66
  // fails overwrites outputs, so the previous record goes first, and only
63
67
  // a run that succeeded writes a new one.
64
68
  const stateFile = getStateFilename(task);
65
- if (await environment.file.exists(stateFile)) {
66
- await environment.file.remove(stateFile);
67
- }
69
+ await removeStateFile(environment, task);
68
70
  for (const generate of (0, get_outputs_to_reset_1.getOutputsToReset)(task.data)) {
69
71
  await environment.file.remove(generate.path);
70
72
  }
71
73
  await (0, local_task_1.localTask)(task, environment, options);
72
74
  const failed = ['crash', 'error', 'canceled'].includes(options.state.current.type);
73
75
  if (!failed) {
74
- await environment.file.writeFile(stateFile, options.stateKey);
76
+ await environment.file.writeFile(stateFile, (0, work_state_record_1.formatStateRecord)(task, options.stateKey));
75
77
  }
76
78
  }
77
79
  finally {
@@ -95,7 +97,7 @@ function getLocalWorkRuntime(task) {
95
97
  return null;
96
98
  }
97
99
  }
98
- return await environment.file.read(stateFileName);
100
+ return (0, work_state_record_1.parseStateRecord)(task, await environment.file.read(stateFileName));
99
101
  },
100
102
  async remove(environment) {
101
103
  for (const generate of task.data.generates) {
@@ -109,6 +111,12 @@ function getLocalWorkRuntime(task) {
109
111
  };
110
112
  }
111
113
  exports.getLocalWorkRuntime = getLocalWorkRuntime;
114
+ async function removeStateFile(environment, task) {
115
+ const stateFile = getStateFilename(task);
116
+ if (await environment.file.exists(stateFile)) {
117
+ await environment.file.remove(stateFile);
118
+ }
119
+ }
112
120
  async function archiveLocal(environment, task, path) {
113
121
  for (const generatedArchive of (0, event_cache_1.getArchivePaths)(task.data, path)) {
114
122
  await environment.file.writeStream(generatedArchive.filename,
@@ -1,6 +1,7 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.getHealthcheckCommand = exports.getServiceCommand = exports.getServiceWorkingDir = exports.isContainerWorkService = void 0;
4
+ const task_arguments_1 = require("./utils/task-arguments");
4
5
  const isContainerWorkService = (svc) => 'image' in svc && svc.type === 'container-service';
5
6
  exports.isContainerWorkService = isContainerWorkService;
6
7
  // The container's working directory: the declared `workdir`, else the build
@@ -10,14 +11,17 @@ exports.getServiceWorkingDir = getServiceWorkingDir;
10
11
  // The container's entrypoint and command for a service: with a `shell`, its
11
12
  // `cmd` runs as `<shell> -c "<cmd>"` in place of the image entrypoint; without
12
13
  // one the tokenized `cmd` becomes the command and the image entrypoint stays.
13
- function getServiceCommand(service) {
14
+ // For Kubernetes, the arguments after -- are escaped so they stay text there.
15
+ function getServiceCommand(service, options = {}) {
14
16
  if (!service.cmd) {
15
17
  return { entrypoint: null, cmd: null };
16
18
  }
17
19
  if (service.shell) {
18
- return { entrypoint: [service.shell, '-c'], cmd: [service.cmd.cmd] };
20
+ const cmd = options.kubernetes ? (0, task_arguments_1.getKubernetesCommandText)(service.cmd) : service.cmd.cmd;
21
+ return { entrypoint: [service.shell, '-c'], cmd: [cmd] };
19
22
  }
20
- return { entrypoint: null, cmd: [service.cmd.parsed.command, ...service.cmd.parsed.args] };
23
+ const args = options.kubernetes ? (0, task_arguments_1.getKubernetesCommandArgs)(service.cmd) : service.cmd.parsed.args;
24
+ return { entrypoint: null, cmd: [service.cmd.parsed.command, ...args] };
21
25
  }
22
26
  exports.getServiceCommand = getServiceCommand;
23
27
  // The healthcheck as an exec command, through the service's `shell` if it has one.
@@ -0,0 +1,30 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.parseStateRecord = exports.formatStateRecord = exports.getStateRecordName = void 0;
4
+ const crypto_1 = require("crypto");
5
+ // The state record says "the outputs at this task's paths were made by this
6
+ // variant of the task (its id), from this state key". It is kept per task
7
+ // *name*, because every variant of a task (other arguments after --, another
8
+ // --set value) writes the same output paths: a record per id would let a run of
9
+ // one variant overwrite the outputs while the record of another one still
10
+ // claims them. The id stored with the key makes the next run of that other
11
+ // variant a miss.
12
+ function getStateRecordName(task) {
13
+ return (0, crypto_1.createHash)('sha1').update(`${task.name}\0${task.data.projectRoot}`).digest('hex');
14
+ }
15
+ exports.getStateRecordName = getStateRecordName;
16
+ function formatStateRecord(task, stateKey) {
17
+ return `${task.id()}:${stateKey}`;
18
+ }
19
+ exports.formatStateRecord = formatStateRecord;
20
+ // The state key of the record when this variant made the outputs, else null.
21
+ function parseStateRecord(task, content) {
22
+ const record = content.trim();
23
+ const separator = record.indexOf(':');
24
+ if (separator < 0 || record.slice(0, separator) !== task.id()) {
25
+ return null;
26
+ }
27
+ return record.slice(separator + 1) || null;
28
+ }
29
+ exports.parseStateRecord = parseStateRecord;
30
+ //# sourceMappingURL=work-state-record.js.map
package/dist/program.js CHANGED
@@ -28,6 +28,7 @@ const cache_inventory_1 = require("./cache/cache-inventory");
28
28
  const remote_commands_1 = require("./remote/remote-commands");
29
29
  const server_command_1 = require("./server/server-command");
30
30
  const run_worker_command_1 = require("./server/run-worker-command");
31
+ const task_arguments_1 = require("./planner/utils/task-arguments");
31
32
  async function createCli(fileName, environment, workScope) {
32
33
  const { ctx, scope } = await (0, schema_parser_1.createParseContext)(fileName, environment);
33
34
  const referencedScope = await (0, reference_parser_1.parseReferences)(ctx, scope, environment);
@@ -80,6 +81,31 @@ function printTaskDefinition(environment, definition) {
80
81
  (0, log_1.printProperty)(environment, `env ${reference.name}`, `from $${reference.from} ${colors_1.default.grey(reference.available ? '(set)' : '(not set)')}`);
81
82
  }
82
83
  }
84
+ // `ls --json`: what a caller (an agent picking a task) needs to know, with the
85
+ // command that arguments after `--` are appended to (null: it takes none).
86
+ function getListJson(tasks, services) {
87
+ return {
88
+ tasks: tasks.map(({ item }) => ({
89
+ name: item.name,
90
+ description: item.data.description,
91
+ type: item.data.type,
92
+ ...(item.data.type === 'container-task' ? { image: item.data.image } : {}),
93
+ deps: item.deps.map((d) => d.name),
94
+ needs: item.needs.map((n) => n.name),
95
+ labels: item.data.labels,
96
+ command: (0, task_arguments_1.getArgumentsTarget)(item.data)?.cmd ?? null,
97
+ })),
98
+ services: services.map(({ item }) => ({
99
+ name: item.name,
100
+ description: item.data.description,
101
+ type: item.data.type,
102
+ ...(item.data.type === 'container-service' ? { image: item.data.image } : {}),
103
+ ports: item.data.ports.map((p) => ({ host: p.hostPort, container: p.containerPort })),
104
+ labels: item.data.labels,
105
+ command: (0, task_arguments_1.getArgumentsTarget)(item.data)?.cmd ?? null,
106
+ })),
107
+ };
108
+ }
83
109
  function parseWorkLabelScope(options) {
84
110
  const scope = {
85
111
  environmentName: null,
@@ -103,11 +129,20 @@ function parseWorkLabelScope(options) {
103
129
  }
104
130
  return scope;
105
131
  }
132
+ // A task given arguments after -- skips the cache, unless the caller asks for a
133
+ // cache method on the command line.
134
+ function keepsCache(command) {
135
+ return command.getOptionValueSource('cache') === 'cli';
136
+ }
106
137
  async function getProgram(environment, argv) {
107
138
  const program = new commander_1.Command();
108
139
  (0, server_command_1.registerServerCommand)(program, environment);
109
140
  (0, run_worker_command_1.registerRunWorkerCommand)(program, environment);
110
- const parsed = (0, parse_set_arguments_1.parseSetArguments)(argv);
141
+ // What follows `--` belongs to the one task or service of a run or up, not to
142
+ // hammerkit: a `--set` or `--file` there is an argument like any other.
143
+ const separator = argv.indexOf('--');
144
+ const passthrough = separator >= 0 ? argv.slice(separator + 1) : [];
145
+ const parsed = (0, parse_set_arguments_1.parseSetArguments)(separator >= 0 ? argv.slice(0, separator) : argv);
111
146
  const args = parsed.args;
112
147
  environment.processEnvs = { ...environment.processEnvs, ...parsed.values };
113
148
  const fileIndex = args.indexOf('--file');
@@ -116,17 +151,28 @@ async function getProgram(environment, argv) {
116
151
  args.splice(fileIndex, 2);
117
152
  }
118
153
  (0, remote_commands_1.registerRemoteCommands)(program, environment, fileName);
154
+ program.hook('preAction', (_, actionCommand) => {
155
+ const takesArguments = actionCommand.parent === program && ['run', 'up'].includes(actionCommand.name());
156
+ if (passthrough.length > 0 && !takesArguments) {
157
+ program.error(`${actionCommand.name()} takes no arguments after --, only run and up do`, { exitCode: 1 });
158
+ }
159
+ });
119
160
  if (await environment.file.exists(fileName)) {
120
161
  program
121
162
  .command('ls')
122
163
  .description('list all tasks')
123
164
  .addOption(new commander_1.Option('-f, --filter <labels...>', 'filter task and services with labels'))
124
165
  .addOption(new commander_1.Option('-e, --exclude <labels...>', 'exclude task and services with labels'))
166
+ .addOption(new commander_1.Option('--json', 'emit the tasks and services as JSON').default(false))
125
167
  .action(async (options) => {
126
168
  const cli = await createCli(fileName, environment, parseWorkLabelScope(options));
127
169
  const items = cli.ls();
128
170
  const tasks = items.filter(cli_1.isCliTask);
129
171
  const services = items.filter(cli_1.isCliService);
172
+ if (options.json) {
173
+ environment.stdout.write(`${JSON.stringify(getListJson(tasks, services), null, 2)}\n`);
174
+ return;
175
+ }
130
176
  if (services.length > 0) {
131
177
  (0, log_1.printTitle)(environment, 'Services');
132
178
  for (const service of services) {
@@ -527,7 +573,8 @@ async function getProgram(environment, argv) {
527
573
  });
528
574
  program
529
575
  .command('up')
530
- .description('start services(s)')
576
+ .description("start services(s), with the arguments after -- appended to the selected service's cmd")
577
+ .usage('[options] [-- args...]')
531
578
  .addOption(new commander_1.Option('-f, --filter <labels...>', 'filter task and services with labels'))
532
579
  .addOption(new commander_1.Option('-e, --exclude <labels...>', 'exclude task and services with labels'))
533
580
  .addOption(new commander_1.Option('-c, --concurrency <number>', 'parallel worker count (default 4, unbounded when every task and service requests cpus and memory)').argParser(parseInt))
@@ -546,9 +593,12 @@ async function getProgram(environment, argv) {
546
593
  .choices(['checksum', 'modify-date', 'none']))
547
594
  .addOption(new commander_1.Option('--cache-read-only', `restore from cache backends but never push to them (or set ${read_only_1.CACHE_READ_ONLY_ENV}=1)`).default(false))
548
595
  .addOption(new commander_1.Option('--timeout <duration>', 'fail tasks without their own timeout after this long (e.g. 10m)').argParser(units_1.parseDuration))
549
- .action(async (options) => {
596
+ .action(async (options, command) => {
550
597
  try {
551
- const scope = parseWorkLabelScope(options);
598
+ const scope = {
599
+ ...parseWorkLabelScope(options),
600
+ args: { values: passthrough, services: true, cache: keepsCache(command) },
601
+ };
552
602
  const cli = await createCli(fileName, environment, scope);
553
603
  const result = await cli.runUp({
554
604
  cacheDefault: options.cache,
@@ -601,7 +651,8 @@ async function getProgram(environment, argv) {
601
651
  });
602
652
  program
603
653
  .command('run [task]', { isDefault: true })
604
- .description('execute task')
654
+ .description('execute task, with the arguments after -- appended to its last command')
655
+ .usage('[options] [task] [-- args...]')
605
656
  .addOption(new commander_1.Option('-f, --filter <labels...>', 'filter task and services with labels'))
606
657
  .addOption(new commander_1.Option('-e, --exclude <labels...>', 'exclude task and services with labels'))
607
658
  .addOption(new commander_1.Option('-c, --concurrency <number>', 'parallel worker count (default 4, unbounded when every task and service requests cpus and memory)').argParser(parseInt))
@@ -621,14 +672,15 @@ async function getProgram(environment, argv) {
621
672
  .addOption(new commander_1.Option('--cache-read-only', `restore from cache backends but never push to them (or set ${read_only_1.CACHE_READ_ONLY_ENV}=1)`).default(false))
622
673
  .addOption(new commander_1.Option('--timeout <duration>', 'fail tasks without their own timeout after this long (e.g. 10m)').argParser(units_1.parseDuration))
623
674
  .addOption(new commander_1.Option('--no-skip-deps', 'run dependencies even when nothing needing them has to run'))
624
- .action(async (task, options) => {
675
+ .action(async (task, options, command) => {
625
676
  try {
626
677
  // For machine-readable JSON, suppress the human progress logger (which
627
678
  // also writes to stdout) so the only thing on stdout is the JSON.
628
679
  const runEnvironment = options.summaryJson
629
680
  ? { ...environment, stdout: (0, empty_writable_1.emptyWritable)(), console: (0, log_1.consoleContext)((0, empty_writable_1.emptyWritable)()) }
630
681
  : environment;
631
- const cli = await createCli(fileName, runEnvironment, task ? { taskName: task, environmentName: options.env } : parseWorkLabelScope(options));
682
+ const args = { values: passthrough, services: false, cache: keepsCache(command) };
683
+ const cli = await createCli(fileName, runEnvironment, task ? { taskName: task, environmentName: options.env, args } : { ...parseWorkLabelScope(options), args });
632
684
  if (cli.tasks().length === 0) {
633
685
  program.error('No tasks found', { exitCode: 127 });
634
686
  return;
@@ -6,6 +6,7 @@ const label_values_1 = require("../executer/label-values");
6
6
  const append_work_service_1 = require("../planner/utils/append-work-service");
7
7
  const append_work_task_1 = require("../planner/utils/append-work-task");
8
8
  const append_work_environment_1 = require("../planner/utils/append-work-environment");
9
+ const task_arguments_1 = require("../planner/utils/task-arguments");
9
10
  function defaultEnvironment(context) {
10
11
  if ('default' in context.environments) {
11
12
  const defaultEnv = context.environments['default'];
@@ -41,17 +42,24 @@ function getWorkContext(context, scope, environment) {
41
42
  // its init task's need on it, which narrowing follows
42
43
  const tasks = Object.values(context.tasks).map((task) => (0, append_work_task_1.appendWorkTask)(workTree, task.cwd, task, environment, context));
43
44
  const services = Object.values(context.services).map((service) => (0, append_work_service_1.appendWorkService)(workTree, service, environment, context));
45
+ const selectedTasks = [];
46
+ const selectedServices = [];
44
47
  for (const item of tasks) {
45
48
  if (matchesTaskWorkScope(item, scope)) {
46
49
  requested.push(item.name);
50
+ selectedTasks.push(item.data);
47
51
  applyTask(filteredWorkTree, item);
48
52
  }
49
53
  }
50
54
  for (const item of services) {
51
55
  if (matchesServiceWorkScope(item.data, scope)) {
56
+ selectedServices.push(item.data);
52
57
  applyService(filteredWorkTree, item);
53
58
  }
54
59
  }
60
+ if (scope.args) {
61
+ (0, task_arguments_1.applyArguments)({ tasks: selectedTasks, services: scope.args.services ? selectedServices : [] }, scope.args.values, scope.args.cache);
62
+ }
55
63
  keepRequirersInScope(filteredWorkTree);
56
64
  return filteredWorkTree;
57
65
  }
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.25.0",
4
+ "version": "1.26.0",
5
5
  "keywords": [
6
6
  "build",
7
7
  "build-tool",