@apex-actions/cli 0.86.0 → 0.88.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.
@@ -1,6 +1,7 @@
1
1
  import { Command } from 'commander';
2
2
  import chalk from 'chalk';
3
- import { decideRun, listWaitingRuns, waitingSummary, } from '../../src/application/manage-approvals.js';
3
+ import { FORK_APPROVAL_DESCRIPTIONS, FORK_APPROVAL_POLICIES, decideRun, forkApprovalPolicy, isForkApprovalPolicy, listWaitingRuns, setForkApprovalPolicy, waitingSummary, } from '../../src/application/manage-approvals.js';
4
+ import { splitRepository } from '../../src/application/manage-caches.js';
4
5
  import { HttpControlPlane, NotConfiguredError } from '../../src/infrastructure/control-plane-http.js';
5
6
  const api = () => {
6
7
  try {
@@ -22,7 +23,7 @@ const printWaiting = (runs) => {
22
23
  console.log([r.id, waitingSummary(r), r.why].join(' '));
23
24
  };
24
25
  const listCommand = new Command('list')
25
- .description('Runs from a fork waiting for somebody with write access to approve them')
26
+ .description('Runs from a fork waiting for an Owner or Admin to approve them')
26
27
  .option('--json', 'machine-readable output')
27
28
  .action(async (opts) => {
28
29
  const client = api();
@@ -69,8 +70,52 @@ const decide = (verb) => new Command(verb)
69
70
  : chalk.yellow(`Rejected ${outcome.decision.runId}. Its jobs are closed as failed.`));
70
71
  }
71
72
  });
73
+ /**
74
+ * `apex approval policy <owner/repo> [policy]` (TASK-0355): which pull requests from forks wait. Beside `list`,
75
+ * `approve` and `reject` because it decides what reaches that queue. No policy prints it; one sets it, which takes
76
+ * an Owner or Admin (administration:write) — it decides whose code starts unread — and is audited by the control plane.
77
+ */
78
+ const policyCommand = new Command('policy')
79
+ .description('Which pull requests from forks wait for approval on a repository; with a policy, set it (an Owner or Admin)')
80
+ .argument('<repository>', 'owner/repo')
81
+ .argument('[policy]', `one of ${FORK_APPROVAL_POLICIES.join(', ')}`)
82
+ .option('--json', 'machine-readable output')
83
+ .action(async (slug, policy, opts) => {
84
+ const target = splitRepository(slug);
85
+ if (!target) {
86
+ console.error(chalk.red(`expected owner/repo, got ${JSON.stringify(slug)}`));
87
+ process.exitCode = 2;
88
+ return;
89
+ }
90
+ if (policy !== undefined && !isForkApprovalPolicy(policy)) {
91
+ console.error(chalk.red(`unknown policy ${JSON.stringify(policy)}: expected one of ${FORK_APPROVAL_POLICIES.join(', ')}`));
92
+ process.exitCode = 2;
93
+ return;
94
+ }
95
+ const client = api();
96
+ if (!client)
97
+ return;
98
+ const outcome = policy === undefined
99
+ ? await forkApprovalPolicy(client, target.owner, target.repo)
100
+ : await setForkApprovalPolicy(client, target.owner, target.repo, policy);
101
+ if (outcome.kind === 'refused') {
102
+ console.error(chalk.red(outcome.detail));
103
+ process.exitCode = 1;
104
+ return;
105
+ }
106
+ if (opts.json === true) {
107
+ console.log(JSON.stringify({ forkApproval: outcome.policy }, null, 2));
108
+ return;
109
+ }
110
+ // A server newer than this CLI may name a policy it does not know; say so rather than print `undefined`.
111
+ const described = FORK_APPROVAL_DESCRIPTIONS[outcome.policy] ?? 'a policy this version of apex does not describe';
112
+ console.log(`${policy === undefined ? '' : `${chalk.green('✔')} `}${slug}: ${chalk.bold(outcome.policy)} — ${described}`);
113
+ // The question behind every change to this: it is not about secrets, and saying so costs one line.
114
+ console.log('A run from a fork never gets secrets, whichever policy is set.');
115
+ });
72
116
  export default new Command('approval')
73
- .description('Runs from forks: what is waiting for approval, and deciding it')
117
+ .description('Runs from forks: what is waiting for approval, deciding it, and which ones wait')
74
118
  .addCommand(listCommand)
75
119
  .addCommand(decide('approve'))
76
- .addCommand(decide('reject'));
120
+ .addCommand(decide('reject'))
121
+ .addCommand(policyCommand);
@@ -1,10 +1,10 @@
1
1
  import { createWriteStream } from 'node:fs';
2
2
  import { Readable } from 'node:stream';
3
3
  import { pipeline } from 'node:stream/promises';
4
- import { basename, resolve } from 'node:path';
4
+ import { resolve } from 'node:path';
5
5
  import { Command } from 'commander';
6
6
  import chalk from 'chalk';
7
- import { deleteArtifact, downloadArtifact, listArtifacts, untilExpiry, } from '../../src/application/manage-artifacts.js';
7
+ import { artifactFileName, deleteArtifact, downloadArtifact, listArtifacts, untilExpiry, } from '../../src/application/manage-artifacts.js';
8
8
  import { humanBytes } from '../../src/application/manage-caches.js';
9
9
  import { HttpControlPlane, NotConfiguredError } from '../../src/infrastructure/control-plane-http.js';
10
10
  /** Build the API client, or explain what is missing and stop. */
@@ -52,10 +52,10 @@ const listCommand = new Command('list')
52
52
  printArtifacts(outcome.artifacts, outcome.totalSizeBytes);
53
53
  });
54
54
  const downloadCommand = new Command('download')
55
- .description('Download one artifact of a run as a zip')
55
+ .description('Download one artifact of a run — a zip, or the file itself when it was uploaded with archive: false')
56
56
  .argument('<run>', 'run id')
57
57
  .argument('[name]', 'artifact name (optional when the run produced exactly one)')
58
- .option('-o, --output <path>', 'where to write the zip (default: <name>.zip here)')
58
+ .option('-o, --output <path>', 'where to write it (default: <name>.zip here, or the file\'s own name)')
59
59
  .action(async (runId, name, opts) => {
60
60
  const client = api();
61
61
  if (!client)
@@ -76,8 +76,20 @@ const downloadCommand = new Command('download')
76
76
  process.exitCode = 1;
77
77
  return;
78
78
  case 'downloaded': {
79
- const target = resolve(opts.output ?? `${basename(outcome.artifact.name)}.zip`);
80
- await pipeline(Readable.from(outcome.body), createWriteStream(target));
79
+ const target = resolve(opts.output ?? artifactFileName(outcome.artifact));
80
+ // The default name comes from the workflow — an unarchived `.env` or
81
+ // `Makefile` is a legal artifact — so it never replaces a file that is
82
+ // already here. A path given with -o is the person's own choice.
83
+ try {
84
+ await pipeline(Readable.from(outcome.body), createWriteStream(target, { flags: opts.output === undefined ? 'wx' : 'w' }));
85
+ }
86
+ catch (err) {
87
+ if (err.code !== 'EEXIST')
88
+ throw err;
89
+ console.error(chalk.red(`${target} already exists; choose where to write it with -o`));
90
+ process.exitCode = 1;
91
+ return;
92
+ }
81
93
  console.log(`${chalk.green('✔')} ${outcome.artifact.name} → ${target} (${humanBytes(outcome.artifact.sizeBytes)})`);
82
94
  }
83
95
  }
@@ -4,7 +4,7 @@ import { deleteLogs, readLogs } from '../../src/application/read-logs.js';
4
4
  import { readLogView } from '../../src/application/search-logs.js';
5
5
  import { HttpControlPlane, NotConfiguredError, RealSleeper } from '../../src/infrastructure/control-plane-http.js';
6
6
  import { authorityNote, totalsPhrase } from '../../src/domain/logsearch.js';
7
- import { parseRepository, unknownRunMessage } from '../../src/domain/runs.js';
7
+ import { parseRepository, policyLines, unknownRunMessage } from '../../src/domain/runs.js';
8
8
  /**
9
9
  * The structured reading of one job's log.
10
10
  *
@@ -97,6 +97,13 @@ const handler = async (run, options) => {
97
97
  if (options.json !== true)
98
98
  printView(section.job.name, read.view);
99
99
  }
100
+ // What the action policy found in the plan that ran (TASK-0343): in `audit` the run went ahead regardless.
101
+ const findings = policyLines(outcome.run);
102
+ if (options.json !== true && findings.length > 0) {
103
+ console.log(chalk.bold('\n── action policy ──'));
104
+ for (const line of findings)
105
+ console.log(` ${chalk.yellow('!')} ${line}`);
106
+ }
100
107
  if (options.json === true)
101
108
  console.log(JSON.stringify({ run: outcome.run, views }, null, 2));
102
109
  process.exitCode = outcome.exitCode;
@@ -2,6 +2,7 @@ import { Command } from 'commander';
2
2
  import chalk from 'chalk';
3
3
  import { serveLanguageServer } from '../../src/application/serve-language-server.js';
4
4
  import { EngineBinaryLanguageServer } from '../../src/infrastructure/engine-binary.js';
5
+ import { resolveBinary } from '../../src/infrastructure/binary-install.js';
5
6
  /**
6
7
  * `apex lsp` — the language server, for an editor to start.
7
8
  *
@@ -33,7 +34,7 @@ export default new Command('lsp')
33
34
  .description('Run the workflow language server on stdio, for an editor to start as a child process')
34
35
  .addHelpText('after', EDITOR_HELP)
35
36
  .action(async () => {
36
- const outcome = await serveLanguageServer(new EngineBinaryLanguageServer());
37
+ const outcome = await serveLanguageServer(new EngineBinaryLanguageServer(await resolveBinary('apex-engine', 'APEX_ENGINE_BIN')));
37
38
  if (outcome.kind === 'engine-missing') {
38
39
  // Named as itself: an editor shows a non-zero exit as "the server
39
40
  // crashed", which sends somebody looking in entirely the wrong place.
package/dist/cli/main.js CHANGED
@@ -1,25 +1,6 @@
1
1
  #!/usr/bin/env node
2
- import { readFileSync } from 'node:fs';
3
- import { dirname, join } from 'node:path';
2
+ import { packageVersion } from '../src/infrastructure/binary-install.js';
4
3
  import { buildProgram } from './program.js';
5
- /**
6
- * The version, from the nearest `package.json` rather than from `VERSION`.
7
- *
8
- * `VERSION` is the source of truth in the repository, and it is not in the published package —
9
- * nor should it be. What ships is `package.json`, npm guarantees it is there, and `version:check`
10
- * guarantees it agrees with `VERSION`. Reading it by walking up also survives the layout changing
11
- * underfoot: `cli/main.ts` under tsx and `dist/cli/main.js` from a tarball sit at different depths,
12
- * and the previous `../VERSION` was correct for exactly one of them.
13
- */
14
- const readVersion = () => {
15
- for (let dir = import.meta.dirname;; dir = dirname(dir)) {
16
- try {
17
- return JSON.parse(readFileSync(join(dir, 'package.json'), 'utf8')).version;
18
- }
19
- catch {
20
- if (dirname(dir) === dir)
21
- return '0.0.0-unknown';
22
- }
23
- }
24
- };
25
- buildProgram(readVersion()).parse(process.argv);
4
+ // The version from the nearest `package.json` — what ships, and what `version:check` keeps equal to
5
+ // `VERSION` (see `packageVersion`); it also names which engine and runner the CLI downloads.
6
+ buildProgram(packageVersion()).parse(process.argv);
@@ -3,6 +3,7 @@ import { Command } from 'commander';
3
3
  import chalk from 'chalk';
4
4
  import { scanOrganization } from '../../src/application/scan-migration.js';
5
5
  import { EngineBinaryCompat, LocalWorkflowSource } from '../../src/infrastructure/engine-binary.js';
6
+ import { resolveBinary } from '../../src/infrastructure/binary-install.js';
6
7
  import { GitHubApi, NoTokenError } from '../../src/infrastructure/github-api.js';
7
8
  import { HttpControlPlane, NotConfiguredError } from '../../src/infrastructure/control-plane-http.js';
8
9
  import { parseRepository } from '../../src/domain/runs.js';
@@ -118,8 +119,8 @@ const printScan = (scan, limit) => {
118
119
  * first thing somebody evaluating a migration wants: an answer about a
119
120
  * repository they already have, without handing anybody credentials.
120
121
  */
121
- const deps = (options) => {
122
- const checker = new EngineBinaryCompat();
122
+ const deps = (options, engine) => {
123
+ const checker = new EngineBinaryCompat(engine);
123
124
  if (options.path !== undefined)
124
125
  return { workflows: new LocalWorkflowSource(options.path), checker };
125
126
  try {
@@ -138,7 +139,7 @@ const handler = async (org, options) => {
138
139
  process.exitCode = 2;
139
140
  return;
140
141
  }
141
- const built = deps(options);
142
+ const built = deps(options, await resolveBinary('apex-engine', 'APEX_ENGINE_BIN'));
142
143
  if (built === undefined)
143
144
  return;
144
145
  // A local scan has no organization. The directory's name is what the person
@@ -126,23 +126,27 @@ export const REFERENCE = {
126
126
  secret: {
127
127
  group: 'config',
128
128
  audience: 'customer',
129
- // The organisation scope answers only for the operator's own organisation.
130
- operatorOptions: ['--org'],
129
+ // `--org <organization>` asks for organization:read|write in that Organization instead (control-plane ADR-0084).
131
130
  subcommandPermissions: { list: 'secrets:read', set: 'secrets:write', delete: 'secrets:write' },
132
131
  examples: [
133
132
  { command: 'printf %s "$NPM_TOKEN" | apex secret set NPM_TOKEN --repo acme/web', description: 'Set a secret from a pipe; a value never goes on the command line.' },
134
133
  { command: 'apex secret set DEPLOY_KEY --repo acme/web --repo acme/api --from-file key.pem', description: 'Set one value on two repositories, from a file.' },
135
134
  { command: 'apex secret list --repo acme/web', description: "A repository's secret names — never values." },
135
+ {
136
+ command: 'printf %s "$NPM_TOKEN" | apex secret set NPM_TOKEN --org acme --repos web,api',
137
+ description: "Set an organization secret once, for the repositories named; it needs organization:write, which an Owner or Account Admin holds.",
138
+ },
139
+ { command: 'apex secret list --org acme', description: "An organization's secret names, and which repositories each reaches." },
136
140
  ],
137
141
  },
138
142
  variable: {
139
143
  group: 'config',
140
144
  audience: 'customer',
141
- operatorOptions: ['--org'],
142
145
  subcommandPermissions: { list: 'variables:read', set: 'variables:write', delete: 'variables:write' },
143
146
  examples: [
144
147
  { command: 'apex variable set NODE_VERSION --value 24 --repo acme/web', description: 'Set a repository variable; a variable is not secret, so --value is allowed.' },
145
148
  { command: 'apex variable list --repo acme/web --json', description: "A repository's variables and their values, as JSON." },
149
+ { command: 'apex variable set REGION --value eu-west-1 --org acme --visibility private', description: "Set an organization variable for its private repositories." },
146
150
  ],
147
151
  },
148
152
  // Runs and logs: a run's repository decides, by the token's `actions` grant.
@@ -230,10 +234,12 @@ export const REFERENCE = {
230
234
  approval: {
231
235
  group: 'config',
232
236
  audience: 'customer',
233
- subcommandPermissions: { list: 'approvals:read', approve: 'approvals:write', reject: 'approvals:write' },
237
+ // `policy` reads with administration:read and sets with administration:write; the reference names what setting needs.
238
+ subcommandPermissions: { list: 'approvals:read', approve: 'approvals:write', reject: 'approvals:write', policy: 'administration:write' },
234
239
  examples: [
235
240
  { command: 'apex approval list', description: 'Runs from forks waiting for somebody to approve them.' },
236
241
  { command: 'apex approval approve 4812', description: 'Approve one: it runs without secrets, on a runner of its own.' },
242
+ { command: 'apex approval policy acme/web first-time', description: 'Hold only first-time contributors’ pull requests from forks, as GitHub does by default.' },
237
243
  ],
238
244
  },
239
245
  schedule: {
@@ -260,11 +266,11 @@ export const REFERENCE = {
260
266
  actions: {
261
267
  group: 'config',
262
268
  audience: 'customer',
263
- // An organisation's action policy is still the operator's.
264
- operatorSubcommands: ['policy'],
265
- subcommandPermissions: { list: 'actions:read', pin: 'administration:write' },
269
+ // An organisation's action policy asks for organization:* in that Organization (control-plane ADR-0084, TASK-0279).
270
+ subcommandPermissions: { list: 'actions:read', pin: 'administration:write', policy: 'organization:read' },
266
271
  examples: [
267
272
  { command: 'apex actions list acme/web', description: 'Every action a repository pulls in, least fixed first.' },
273
+ { command: 'apex actions policy acme --mode enforce --allow "actions/*"', description: "Allow only GitHub's own actions in an organization's workflows; changing it needs organization:write." },
268
274
  { command: 'apex actions pin acme/web --apply', description: 'Open a pull request pinning each action to the commit its version points at.' },
269
275
  ],
270
276
  },
@@ -2,10 +2,11 @@ import { Command } from 'commander';
2
2
  import chalk from 'chalk';
3
3
  import { runWorkflow } from '../../src/application/run-workflow.js';
4
4
  import { RunnerBinaryExecutor } from '../../src/infrastructure/runner-binary.js';
5
+ import { resolveBinary } from '../../src/infrastructure/binary-install.js';
5
6
  import { GitHubWorkflowLocator } from '../../src/infrastructure/engine-binary.js';
6
7
  const collect = (value, previous) => [...previous, value];
7
8
  const handler = async (workflow, options) => {
8
- const outcome = await runWorkflow(new RunnerBinaryExecutor(), new GitHubWorkflowLocator(), workflow, {
9
+ const outcome = await runWorkflow(new RunnerBinaryExecutor(await resolveBinary('apex-runner', 'APEX_RUNNER_BIN')), new GitHubWorkflowLocator(), workflow, {
9
10
  ...(options.job !== undefined ? { job: options.job } : {}),
10
11
  ...(options.event !== undefined ? { event: options.event } : {}),
11
12
  ...(options.payload !== undefined ? { payload: options.payload } : {}),
@@ -4,6 +4,7 @@ import { fleetHistory, listRunners, mintRegistrationToken, registerRunner, remov
4
4
  import { humanSeconds, parseDuration } from '../../src/domain/fleet-history.js';
5
5
  import { HttpControlPlane, NotConfiguredError } from '../../src/infrastructure/control-plane-http.js';
6
6
  import { RunnerBinaryExecutor } from '../../src/infrastructure/runner-binary.js';
7
+ import { resolveBinary } from '../../src/infrastructure/binary-install.js';
7
8
  /** Build the API client, or explain what is missing and stop. */
8
9
  const api = () => {
9
10
  try {
@@ -142,7 +143,7 @@ const registerCommand = new Command('register')
142
143
  // This process becomes the runner: it runs until stopped, and executes
143
144
  // other people's code on this machine. Say so before it starts.
144
145
  console.error(chalk.dim(`registering ${opts.name ?? 'this machine'} with ${opts.server} as [${opts.labels}]`));
145
- const outcome = await registerRunner(client, new RunnerBinaryExecutor(), {
146
+ const outcome = await registerRunner(client, new RunnerBinaryExecutor(await resolveBinary('apex-runner', 'APEX_RUNNER_BIN')), {
146
147
  server: opts.server,
147
148
  labels: opts.labels.split(','),
148
149
  name: opts.name,
@@ -2,7 +2,7 @@ import { Command, Option } from 'commander';
2
2
  import chalk from 'chalk';
3
3
  import { batchExitCode, listSecrets, setEverywhere } from '../../src/application/manage-secrets.js';
4
4
  import { ValueInputError, promptHidden, readToEnd, readValueFile } from '../../src/infrastructure/secret-input.js';
5
- import { api, collect, deleteAction, printResults, targetsFrom, usage, validName } from './shared.js';
5
+ import { api, collect, deleteAction, ORG_OPTION, printResults, reachLabel, reachOf, targetsFrom, usage, validName } from './shared.js';
6
6
  /**
7
7
  * The refusal, in full, because the person who typed `--value` deserves the
8
8
  * reason and the alternative in one breath (BUG-0157).
@@ -30,7 +30,9 @@ const setCommand = new Command('set')
30
30
  .description('Set a secret on one or more repositories; the value is read from stdin, a file or a no-echo prompt')
31
31
  .argument('<name>', 'secret name, as a workflow reads it: secrets.NAME')
32
32
  .option('--repo <owner/repo>', 'repository to set it on (repeatable: one value, many repositories)', collect, [])
33
- .option('--org', 'the organisation scope (the operator organisation only)')
33
+ .option(...ORG_OPTION)
34
+ .option('--visibility <visibility>', 'with --org <organization>: which repositories reach it — all (default), private or selected')
35
+ .option('--repos <names>', 'with --org <organization>: the repositories that reach it, comma-separated (implies --visibility selected)')
34
36
  .option('--from-file <path>', 'read the value from this file (one trailing newline is dropped)')
35
37
  // Declared so the refusal is ours, with the reason, and not commander's "unknown option".
36
38
  .addOption(new Option('--value [value]', 'refused: a secret never goes on the command line').hideHelp())
@@ -44,6 +46,9 @@ const setCommand = new Command('set')
44
46
  const targets = targetsFrom(opts);
45
47
  if (!targets)
46
48
  return;
49
+ const reach = reachOf(opts);
50
+ if (reach === null)
51
+ return;
47
52
  const client = api();
48
53
  if (!client)
49
54
  return;
@@ -54,14 +59,14 @@ const setCommand = new Command('set')
54
59
  usage('the value is empty; nothing was set');
55
60
  return;
56
61
  }
57
- const results = await setEverywhere(client, 'secret', targets, name, value);
62
+ const results = await setEverywhere(client, 'secret', targets, name, value, reach);
58
63
  printResults(name, results);
59
64
  process.exitCode = batchExitCode(results);
60
65
  });
61
66
  const listCommand = new Command('list')
62
67
  .description("A repository's secret names — never values: the API has none to give")
63
68
  .option('--repo <owner/repo>', 'repository', collect, [])
64
- .option('--org', 'the organisation scope (the operator organisation only)')
69
+ .option(...ORG_OPTION)
65
70
  .option('--json', 'machine-readable output')
66
71
  .action(async (opts) => {
67
72
  const targets = targetsFrom(opts, true);
@@ -84,15 +89,17 @@ const listCommand = new Command('list')
84
89
  console.log(chalk.yellow('No secrets are set here.'));
85
90
  return;
86
91
  }
87
- console.log(chalk.bold(['NAME'.padEnd(32), 'UPDATED'].join(' ')));
88
- for (const s of outcome.secrets)
89
- console.log([s.name.padEnd(32), s.updatedAt ?? ''].join(' '));
92
+ const reaches = outcome.secrets.some((s) => s.visibility !== undefined);
93
+ console.log(chalk.bold(['NAME'.padEnd(32), 'UPDATED'.padEnd(reaches ? 26 : 0), ...(reaches ? ['REACHES'] : [])].join(' ').trimEnd()));
94
+ for (const s of outcome.secrets) {
95
+ console.log([s.name.padEnd(32), (s.updatedAt ?? '').padEnd(reaches ? 26 : 0), ...(reaches ? [reachLabel(s)] : [])].join(' ').trimEnd());
96
+ }
90
97
  });
91
98
  const deleteCommand = new Command('delete')
92
99
  .description('Delete a secret from one or more repositories')
93
100
  .argument('<name>', 'secret name')
94
101
  .option('--repo <owner/repo>', 'repository (repeatable)', collect, [])
95
- .option('--org', 'the organisation scope (the operator organisation only)')
102
+ .option(...ORG_OPTION)
96
103
  .option('-y, --yes', 'do not ask; required when not on a terminal')
97
104
  .action(deleteAction('secret'));
98
105
  export default new Command('secret')
@@ -2,8 +2,10 @@ import chalk from 'chalk';
2
2
  import { splitRepository } from '../../src/application/manage-caches.js';
3
3
  import { batchExitCode, deleteEverywhere } from '../../src/application/manage-secrets.js';
4
4
  import { confirm } from '../../src/infrastructure/secret-input.js';
5
- import { nameProblem } from '../../src/domain/secrets.js';
5
+ import { nameProblem, reachFrom } from '../../src/domain/secrets.js';
6
6
  import { HttpControlPlane, NotConfiguredError } from '../../src/infrastructure/control-plane-http.js';
7
+ /** What `--org` says in every subcommand's help. */
8
+ export const ORG_OPTION = ['--org [organization]', 'an organization’s scope, by its GitHub login (bare: the operator organization)'];
7
9
  export const collect = (value, previous) => [...previous, value];
8
10
  export const usage = (message) => {
9
11
  console.error(chalk.red(message));
@@ -31,12 +33,14 @@ export const validName = (name) => {
31
33
  * is for `list`, which answers about one place at a time.
32
34
  */
33
35
  export const targetsFrom = (opts, single = false) => {
34
- if (opts.org === true && opts.repo.length > 0) {
36
+ if (opts.org !== undefined && opts.org !== false && opts.repo.length > 0) {
35
37
  usage('pass --repo or --org, not both');
36
38
  return undefined;
37
39
  }
38
40
  if (opts.org === true)
39
41
  return [{ kind: 'org' }];
42
+ if (typeof opts.org === 'string')
43
+ return [{ kind: 'org', org: opts.org }];
40
44
  if (opts.repo.length === 0) {
41
45
  usage('name a repository with --repo owner/repo');
42
46
  return undefined;
@@ -56,6 +60,26 @@ export const targetsFrom = (opts, single = false) => {
56
60
  }
57
61
  return targets;
58
62
  };
63
+ /**
64
+ * `--visibility` and `--repos`, which say which of a named Organization's repositories an entry reaches; `null` after
65
+ * a usage error, `undefined` for the API's default (every repository).
66
+ */
67
+ export const reachOf = (opts) => {
68
+ if (opts.visibility === undefined && opts.repos === undefined)
69
+ return undefined;
70
+ if (typeof opts.org !== 'string') {
71
+ usage('--visibility and --repos say which repositories of an organization reach it: use them with --org <organization>');
72
+ return null;
73
+ }
74
+ const reach = reachFrom(opts.visibility, opts.repos);
75
+ if (reach !== undefined && 'problem' in reach) {
76
+ usage(reach.problem);
77
+ return null;
78
+ }
79
+ return reach;
80
+ };
81
+ /** An Organization's entry: which repositories reach it, as a person reads it. */
82
+ export const reachLabel = (r) => r.visibility === 'selected' ? `selected: ${(r.selectedRepositories ?? []).join(', ') || 'none'}` : r.visibility === 'private' ? 'private repositories' : 'all repositories';
59
83
  /** One line per target, then nothing else: the summary is the whole output. */
60
84
  export const printResults = (name, results) => {
61
85
  for (const r of results) {
@@ -1,10 +1,10 @@
1
1
  import { Command } from 'commander';
2
2
  import chalk from 'chalk';
3
- import { listDepartures, purgeDeparture } from '../../src/application/manage-departures.js';
3
+ import { listDepartures, listRepositoryDepartures, purgeDeparture, purgeRepositoryDeparture } from '../../src/application/manage-departures.js';
4
4
  import { backfillAccounts, connectInstallation, listUnconnected } from '../../src/application/manage-accounts.js';
5
5
  import { backfillLines, connectLine, unconnectedLine } from '../../src/domain/accounts.js';
6
6
  import { HttpControlPlane, NotConfiguredError } from '../../src/infrastructure/control-plane-http.js';
7
- import { countsLine, departureLine } from '../../src/domain/departures.js';
7
+ import { countsLine, departureLine, repositoryDepartureLine } from '../../src/domain/departures.js';
8
8
  import { listInstallations } from '../../src/application/manage-grants.js';
9
9
  import { installationLine } from '../../src/domain/grants.js';
10
10
  /** Build the API client, or explain what is missing and stop. */
@@ -20,11 +20,31 @@ const api = () => {
20
20
  };
21
21
  const departuresCommand = new Command('departures')
22
22
  .description('Every tenant that uninstalled, newest first, and when its data is deleted')
23
+ .option('--repositories', 'repositories removed from an installation that stays, instead (ADR-0069 amendment)')
23
24
  .option('--json', 'machine-readable output')
24
25
  .action(async (opts) => {
25
26
  const client = api();
26
27
  if (!client)
27
28
  return;
29
+ if (opts.repositories === true) {
30
+ const removed = await listRepositoryDepartures(client);
31
+ if (removed.kind === 'refused') {
32
+ console.error(chalk.red(removed.message));
33
+ process.exitCode = 1;
34
+ return;
35
+ }
36
+ if (opts.json === true) {
37
+ console.log(JSON.stringify(removed.value, null, 2));
38
+ return;
39
+ }
40
+ if (removed.value.length === 0) {
41
+ console.log(chalk.green('no repository has been removed'));
42
+ return;
43
+ }
44
+ for (const d of removed.value)
45
+ console.log(` ${repositoryDepartureLine(d)}`);
46
+ return;
47
+ }
28
48
  const outcome = await listDepartures(client);
29
49
  if (outcome.kind === 'refused') {
30
50
  console.error(chalk.red(outcome.message));
@@ -42,12 +62,55 @@ const departuresCommand = new Command('departures')
42
62
  for (const d of outcome.value)
43
63
  console.log(` ${departureLine(d)}`);
44
64
  });
65
+ /** `apex tenant purge --repository <repoId>` (context TASK-0331). */
66
+ const purgeRemovedRepository = async (raw, opts) => {
67
+ const repoId = Number(raw);
68
+ if (!Number.isSafeInteger(repoId) || repoId <= 0) {
69
+ console.error(chalk.red(`expected a positive repository id, got ${JSON.stringify(raw)}`));
70
+ process.exitCode = 2;
71
+ return;
72
+ }
73
+ const client = api();
74
+ if (!client)
75
+ return;
76
+ const outcome = await purgeRepositoryDeparture(client, repoId, opts.apply === true);
77
+ if (outcome.kind === 'refused') {
78
+ console.error(chalk.red(outcome.message));
79
+ process.exitCode = 1;
80
+ return;
81
+ }
82
+ const report = outcome.value;
83
+ if (opts.json === true) {
84
+ console.log(JSON.stringify(report, null, 2));
85
+ return;
86
+ }
87
+ console.log(repositoryDepartureLine(report.departure));
88
+ if (report.applied && report.counts !== null) {
89
+ console.log(`${chalk.green('✔')} purged: ${countsLine(report.counts)}`);
90
+ return;
91
+ }
92
+ if (report.departure.purgedAt !== null) {
93
+ console.log(chalk.dim(`already purged${report.counts === null ? '' : `: ${countsLine(report.counts)}`}`));
94
+ return;
95
+ }
96
+ console.log(chalk.yellow(`the repository and everything under it would be deleted, with the deliveries that name it. Re-run with --apply.`));
97
+ };
45
98
  const purgeCommand = new Command('purge')
46
99
  .description("Delete one departed tenant's data now, whatever its window — a dry run unless --apply")
47
- .argument('<installationId>', 'the installation that left')
100
+ .argument('[installationId]', 'the installation that left')
101
+ .option('--repository <repoId>', 'a repository removed from an installation that stays, instead (ADR-0069 amendment)')
48
102
  .option('--apply', 'delete it (default: report what would be deleted)')
49
103
  .option('--json', 'machine-readable output')
50
104
  .action(async (raw, opts) => {
105
+ if ((raw === undefined) === (opts.repository === undefined)) {
106
+ console.error(chalk.red('name one: an installation id, or --repository <repoId>'));
107
+ process.exitCode = 2;
108
+ return;
109
+ }
110
+ if (opts.repository !== undefined) {
111
+ await purgeRemovedRepository(opts.repository, opts);
112
+ return;
113
+ }
51
114
  const installationId = Number(raw);
52
115
  if (!Number.isSafeInteger(installationId) || installationId <= 0) {
53
116
  console.error(chalk.red(`expected a positive installation id, got ${JSON.stringify(raw)}`));
@@ -2,6 +2,7 @@ import { Command } from 'commander';
2
2
  import chalk from 'chalk';
3
3
  import { validateWorkflows } from '../../src/application/validate-workflows.js';
4
4
  import { EngineBinaryValidator, GitHubWorkflowLocator } from '../../src/infrastructure/engine-binary.js';
5
+ import { resolveBinary } from '../../src/infrastructure/binary-install.js';
5
6
  import { diagnosticCount } from '../../src/domain/validation.js';
6
7
  const printHuman = (report) => {
7
8
  for (const file of report.files) {
@@ -21,7 +22,7 @@ const printHuman = (report) => {
21
22
  console.log(chalk.dim(`${n} diagnostic${n === 1 ? '' : 's'}`));
22
23
  };
23
24
  const handler = async (files, options) => {
24
- const outcome = await validateWorkflows(new EngineBinaryValidator(), new GitHubWorkflowLocator(), files);
25
+ const outcome = await validateWorkflows(new EngineBinaryValidator(await resolveBinary('apex-engine', 'APEX_ENGINE_BIN')), new GitHubWorkflowLocator(), files);
25
26
  switch (outcome.kind) {
26
27
  case 'no-workflows':
27
28
  console.error(chalk.yellow('no workflow files found (.github/workflows/*.yml) — pass files explicitly'));
@@ -2,7 +2,7 @@ import { Command } from 'commander';
2
2
  import chalk from 'chalk';
3
3
  import { batchExitCode, listVariables, setEverywhere } from '../../src/application/manage-secrets.js';
4
4
  import { readToEnd } from '../../src/infrastructure/secret-input.js';
5
- import { api, collect, deleteAction, printResults, targetsFrom, usage, validName } from '../secret/shared.js';
5
+ import { api, collect, deleteAction, ORG_OPTION, printResults, reachLabel, reachOf, targetsFrom, usage, validName } from '../secret/shared.js';
6
6
  /**
7
7
  * `apex variable`: the same routes as `apex secret`, for values that are not
8
8
  * secret. A variable is readable back — `list` prints it — so `--value` on the
@@ -12,14 +12,19 @@ const setCommand = new Command('set')
12
12
  .description('Set a variable on one or more repositories, from --value or stdin')
13
13
  .argument('<name>', 'variable name, as a workflow reads it: vars.NAME')
14
14
  .option('--value <value>', 'the value (not secret, so a flag is allowed); otherwise read from stdin')
15
+ .option('--visibility <visibility>', 'with --org <organization>: which repositories reach it — all (default), private or selected')
16
+ .option('--repos <names>', 'with --org <organization>: the repositories that reach it, comma-separated (implies --visibility selected)')
15
17
  .option('--repo <owner/repo>', 'repository to set it on (repeatable)', collect, [])
16
- .option('--org', 'the organisation scope (the operator organisation only)')
18
+ .option(...ORG_OPTION)
17
19
  .action(async (name, opts) => {
18
20
  if (!validName(name))
19
21
  return;
20
22
  const targets = targetsFrom(opts);
21
23
  if (!targets)
22
24
  return;
25
+ const reach = reachOf(opts);
26
+ if (reach === null)
27
+ return;
23
28
  let value = opts.value;
24
29
  if (value === undefined) {
25
30
  if (process.stdin.isTTY === true) {
@@ -35,14 +40,14 @@ const setCommand = new Command('set')
35
40
  const client = api();
36
41
  if (!client)
37
42
  return;
38
- const results = await setEverywhere(client, 'variable', targets, name, value);
43
+ const results = await setEverywhere(client, 'variable', targets, name, value, reach);
39
44
  printResults(name, results);
40
45
  process.exitCode = batchExitCode(results);
41
46
  });
42
47
  const listCommand = new Command('list')
43
48
  .description("A repository's variables, names and values")
44
49
  .option('--repo <owner/repo>', 'repository', collect, [])
45
- .option('--org', 'the organisation scope (the operator organisation only)')
50
+ .option(...ORG_OPTION)
46
51
  .option('--json', 'machine-readable output')
47
52
  .action(async (opts) => {
48
53
  const targets = targetsFrom(opts, true);
@@ -65,15 +70,17 @@ const listCommand = new Command('list')
65
70
  console.log(chalk.yellow('No variables are set here.'));
66
71
  return;
67
72
  }
68
- console.log(chalk.bold(['NAME'.padEnd(32), 'VALUE'].join(' ')));
69
- for (const v of outcome.variables)
70
- console.log([v.name.padEnd(32), v.value].join(' '));
73
+ const reaches = outcome.variables.some((v) => v.visibility !== undefined);
74
+ console.log(chalk.bold(['NAME'.padEnd(32), 'VALUE'.padEnd(reaches ? 32 : 0), ...(reaches ? ['REACHES'] : [])].join(' ').trimEnd()));
75
+ for (const v of outcome.variables) {
76
+ console.log([v.name.padEnd(32), v.value.padEnd(reaches ? 32 : 0), ...(reaches ? [reachLabel(v)] : [])].join(' ').trimEnd());
77
+ }
71
78
  });
72
79
  const deleteCommand = new Command('delete')
73
80
  .description('Delete a variable from one or more repositories')
74
81
  .argument('<name>', 'variable name')
75
82
  .option('--repo <owner/repo>', 'repository (repeatable)', collect, [])
76
- .option('--org', 'the organisation scope (the operator organisation only)')
83
+ .option(...ORG_OPTION)
77
84
  .option('-y, --yes', 'do not ask; required when not on a terminal')
78
85
  .action(deleteAction('variable'));
79
86
  export default new Command('variable')
@@ -1,4 +1,17 @@
1
1
  // application layer — see ../../../../docs/conventions.md §1
2
+ /**
3
+ * Which pull requests from forks wait for approval — a repository's choice (TASK-0355). `every-fork` is ours and the
4
+ * default; the other three are GitHub's. Whatever is chosen, a fork's run gets no secrets.
5
+ */
6
+ export const FORK_APPROVAL_POLICIES = ['every-fork', 'external', 'first-time', 'first-time-new-to-github'];
7
+ /** Each policy in GitHub's words, and who waits under it. */
8
+ export const FORK_APPROVAL_DESCRIPTIONS = {
9
+ 'every-fork': 'every pull request from a fork waits for approval, whoever opened it (the default)',
10
+ external: 'require approval for all external contributors — anybody not an owner, member or collaborator waits',
11
+ 'first-time': 'require approval for first-time contributors — anybody never merged into this repository waits',
12
+ 'first-time-new-to-github': 'require approval for first-time contributors who are new to GitHub',
13
+ };
14
+ export const isForkApprovalPolicy = (v) => FORK_APPROVAL_POLICIES.includes(v);
2
15
  const refused = (err) => ({ kind: 'refused', detail: err instanceof Error ? err.message : String(err) });
3
16
  export const listWaitingRuns = async (api) => {
4
17
  try {
@@ -22,3 +35,21 @@ export const decideRun = async (api, runId, decision, comment) => {
22
35
  };
23
36
  /** One waiting run on one line: what it is, and where it came from. */
24
37
  export const waitingSummary = (r) => `${r.repository} #${r.runNumber} ${r.workflowName} (${r.event} ${r.sha.slice(0, 7)})`;
38
+ /** The repository's fork-approval policy — the effective one, so a repository that never chose reads `every-fork`. */
39
+ export const forkApprovalPolicy = async (api, owner, repo) => {
40
+ try {
41
+ return { kind: 'read', policy: (await api.getForkApproval(owner, repo)).forkApproval };
42
+ }
43
+ catch (err) {
44
+ return refused(err);
45
+ }
46
+ };
47
+ /** Choose the policy (repository admin). The control plane audits it and answers with what it stored. */
48
+ export const setForkApprovalPolicy = async (api, owner, repo, policy) => {
49
+ try {
50
+ return { kind: 'read', policy: (await api.setForkApproval(owner, repo, policy)).forkApproval };
51
+ }
52
+ catch (err) {
53
+ return refused(err);
54
+ }
55
+ };
@@ -77,3 +77,13 @@ export const untilExpiry = (iso, now = Date.now()) => {
77
77
  const days = Math.floor(ms / 86_400_000);
78
78
  return days === 0 ? 'today' : `${days}d`;
79
79
  };
80
+ /**
81
+ * Where a download is written when nobody says: the server's `fileName`, else
82
+ * `<name>.zip`, which is all a server before raw uploads ever stored. Only the
83
+ * last path segment — a name never steers the write elsewhere.
84
+ */
85
+ export const artifactFileName = (artifact) => {
86
+ const last = (artifact.fileName ?? `${artifact.name}.zip`).split(/[\\/]/).pop() ?? '';
87
+ // `artifact` is what `@actions/artifact` itself falls back to.
88
+ return last === '' || last === '.' || last === '..' ? 'artifact' : last;
89
+ };
@@ -16,3 +16,7 @@ export const listDepartures = async (api) => await attempt(() => api.departures(
16
16
  * left to ask.
17
17
  */
18
18
  export const purgeDeparture = async (api, installationId, apply) => await attempt(() => api.purgeDeparture(installationId, apply));
19
+ /** Every repository the App was removed from, newest first (control-plane ADR-0069 amendment). */
20
+ export const listRepositoryDepartures = async (api) => await attempt(() => api.repositoryDepartures());
21
+ /** Purge one removed repository's data now, whatever its window — a dry run unless `apply`; machine token only. */
22
+ export const purgeRepositoryDeparture = async (api, repoId, apply) => await attempt(() => api.purgeRepositoryDeparture(repoId, apply));
@@ -1,17 +1,17 @@
1
1
  // application layer — see ../../../../docs/conventions.md §1
2
2
  import { targetLabel } from '../domain/secrets.js';
3
3
  /**
4
- * Why the organisation scope refused: it answers only for the operator
5
- * organisation (BUG-0112), and a customer token has no scope there at all.
6
- * A bare 403 would read as a permissions problem somebody could fix.
4
+ * Why the unnamed organisation scope refused: it is the operator organisation's
5
+ * (BUG-0112). A customer names theirs — `--org <login>` (ADR-0084) — and that
6
+ * route's refusal explains itself, so it is relayed as worded.
7
7
  */
8
- export const ORG_SCOPE_REFUSAL = 'organisation secrets are not available to customer organisations yet — set it on each repository with --repo';
8
+ export const ORG_SCOPE_REFUSAL = 'the bare --org scope is the operator organisation’s — name yours: --org <your-organization>';
9
9
  const statusOf = (err) => {
10
10
  const status = err?.status;
11
11
  return typeof status === 'number' ? status : undefined;
12
12
  };
13
13
  const refused = (target, err) => {
14
- if (target.kind === 'org' && statusOf(err) === 403)
14
+ if (target.kind === 'org' && target.org === undefined && statusOf(err) === 403)
15
15
  return { kind: 'refused', detail: ORG_SCOPE_REFUSAL };
16
16
  return { kind: 'refused', detail: err instanceof Error ? err.message : String(err) };
17
17
  };
@@ -22,11 +22,11 @@ const refused = (target, err) => {
22
22
  * one token onto several repositories, and stopping half-way would leave the
23
23
  * person to work out which half. The summary says, per target.
24
24
  */
25
- export const setEverywhere = async (api, kind, targets, name, value) => {
25
+ export const setEverywhere = async (api, kind, targets, name, value, reach) => {
26
26
  const results = [];
27
27
  for (const target of targets) {
28
28
  try {
29
- await (kind === 'secret' ? api.setSecret(target, name, value) : api.setVariable(target, name, value));
29
+ await (kind === 'secret' ? api.setSecret(target, name, value, reach) : api.setVariable(target, name, value, reach));
30
30
  results.push({ target: targetLabel(target), kind: 'set' });
31
31
  }
32
32
  catch (err) {
@@ -64,7 +64,12 @@ export const listSecrets = async (api, target) => {
64
64
  return {
65
65
  kind: 'listed',
66
66
  secrets: secrets
67
- .map((s) => ({ name: s.name, ...(s.updatedAt === undefined ? {} : { updatedAt: s.updatedAt }) }))
67
+ .map((s) => ({
68
+ name: s.name,
69
+ ...(s.updatedAt === undefined ? {} : { updatedAt: s.updatedAt }),
70
+ ...(s.visibility === undefined ? {} : { visibility: s.visibility }),
71
+ ...(s.selectedRepositories === undefined ? {} : { selectedRepositories: s.selectedRepositories }),
72
+ }))
68
73
  .sort((a, b) => (a.name < b.name ? -1 : 1)),
69
74
  };
70
75
  }
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * The Accounts backfill as the control plane reports it (control-plane
3
- * `POST /api/v1/admin/accounts/backfill`, TASK-0195), and how a person reads it.
3
+ * `POST /api/v1/console/accounts/backfill`, TASK-0195), and how a person reads it.
4
4
  */
5
5
  const ORG_VERB = {
6
6
  connect: ['would join', 'joined'],
@@ -131,7 +131,7 @@ const REPO = /^[A-Za-z0-9-]+\/[A-Za-z0-9._-]+$/;
131
131
  * What a token may be granted (context ADR-0038): `resource:read|write`, write implying read. The control plane's
132
132
  * `RepoPermission`, in the order a person reads them.
133
133
  */
134
- export const TOKEN_RESOURCES = ['actions', 'approvals', 'deployments', 'secrets', 'variables', 'environments', 'notifications', 'administration'];
134
+ export const TOKEN_RESOURCES = ['actions', 'approvals', 'deployments', 'secrets', 'variables', 'environments', 'notifications', 'administration', 'organization'];
135
135
  export const TOKEN_PERMISSIONS = TOKEN_RESOURCES.flatMap((r) => [`${r}:read`, `${r}:write`]);
136
136
  /** `--permission`'s values: at least one when `required`, each one a known grant. */
137
137
  export const permissionsProblem = (permissions, required) => {
@@ -1,4 +1,9 @@
1
1
  // domain layer — see ../../../../docs/conventions.md §1
2
+ /** Where a removed repository's clock stands: gone, kept because it was added back, or still waiting. */
3
+ export const repositoryDepartureLine = (d) => {
4
+ const state = d.purgedAt !== null ? `purged ${d.purgedAt}` : d.withdrawnAt !== null ? `kept — added back ${d.withdrawnAt}` : `due ${d.purgeAfter}`;
5
+ return `${d.repository} (repository ${d.repoId}) — removed from installation ${d.installationId} ${d.departedAt}; ${state}`;
6
+ };
2
7
  /**
3
8
  * Where a departure stands, in the words an operator answering a deletion
4
9
  * request needs: gone, kept because the account came back, or still waiting —
@@ -1,6 +1,8 @@
1
1
  // domain layer — see ../../../../docs/conventions.md §1
2
2
  // What the CLI knows about runs and jobs, and the pure decisions it makes
3
3
  // about them. The REST shapes come from control-plane/contracts/openapi.
4
+ /** The run's action-policy findings as `apex logs --summary` prints them: where, which job, the policy's sentence. */
5
+ export const policyLines = (run) => (run.policyFindings ?? []).map((f) => `${f.workflowPath}${f.line === undefined || f.line <= 0 ? '' : `:${String(f.line)}`} (job ${f.jobId}): ${f.reason}`);
4
6
  /** A run is worth following while anything can still change. */
5
7
  export const isLive = (status) => status !== 'completed';
6
8
  /** The exit code a completed run should give a shell: 0 only for success. */
@@ -1,5 +1,26 @@
1
1
  // domain layer — see ../../../../docs/conventions.md §1
2
- export const targetLabel = (target) => (target.kind === 'org' ? 'the organisation' : `${target.owner}/${target.repo}`);
2
+ export const targetLabel = (target) => target.kind === 'repo' ? `${target.owner}/${target.repo}` : target.org === undefined ? 'the organisation' : `the ${target.org} organization`;
3
+ /** Which of an Organization's repositories an organisation entry reaches — GitHub's three. */
4
+ export const VISIBILITIES = ['all', 'private', 'selected'];
5
+ /**
6
+ * `--visibility` and `--repos` into a reach, or the reason they do not make one. `--repos` alone means `selected`,
7
+ * as on `gh secret set`; neither means the API's default, every repository.
8
+ */
9
+ export const reachFrom = (visibility, repos) => {
10
+ const names = (repos ?? '')
11
+ .split(',')
12
+ .map((r) => r.trim())
13
+ .filter((r) => r !== '');
14
+ if (visibility === undefined && names.length === 0)
15
+ return undefined;
16
+ if (visibility !== undefined && !VISIBILITIES.includes(visibility)) {
17
+ return { problem: `--visibility is one of ${VISIBILITIES.join(', ')}, not ${JSON.stringify(visibility)}` };
18
+ }
19
+ const v = (visibility ?? 'selected');
20
+ if (v !== 'selected' && names.length > 0)
21
+ return { problem: `--repos goes with --visibility selected, not ${v}` };
22
+ return { visibility: v, repos: names };
23
+ };
3
24
  /**
4
25
  * GitHub's naming rule, which the control plane enforces too: letters, digits
5
26
  * and underscores, not starting with a digit, and `GITHUB_` is reserved.
@@ -0,0 +1,147 @@
1
+ // infrastructure layer — see ../../../../docs/conventions.md §1
2
+ import { execFile } from 'node:child_process';
3
+ import { createHash } from 'node:crypto';
4
+ import { readFileSync } from 'node:fs';
5
+ import { access, chmod, mkdir, mkdtemp, rename, rm, writeFile } from 'node:fs/promises';
6
+ import { constants } from 'node:fs';
7
+ import { homedir } from 'node:os';
8
+ import { delimiter, dirname, join } from 'node:path';
9
+ import { promisify } from 'node:util';
10
+ /**
11
+ * Where `engine` and `runner` publish their binaries on a release tag (context ADR-0040): one archive per
12
+ * platform and a SHA256SUMS file per command, under the lockstep version.
13
+ */
14
+ export const DIST_BASE = 'https://apexactions.com/dl';
15
+ /**
16
+ * The CLI's version, from the nearest `package.json` above this file.
17
+ *
18
+ * `VERSION` is the source of truth in the repository and is not in the published package; what ships is
19
+ * `package.json`, and `version:check` guarantees the two agree. Walking up survives the layout differing
20
+ * between `tsx` (`src/…`) and a tarball (`dist/src/…`).
21
+ */
22
+ export const packageVersion = (from = import.meta.dirname) => {
23
+ for (let dir = from;; dir = dirname(dir)) {
24
+ try {
25
+ return JSON.parse(readFileSync(join(dir, 'package.json'), 'utf8')).version;
26
+ }
27
+ catch {
28
+ if (dirname(dir) === dir)
29
+ return '0.0.0-unknown';
30
+ }
31
+ }
32
+ };
33
+ /** Node's names to Go's, for the four targets that are published; anything else has no download. */
34
+ export const platformOf = (platform, arch) => {
35
+ const os = platform === 'darwin' ? 'darwin' : platform === 'linux' ? 'linux' : undefined;
36
+ const goArch = arch === 'arm64' ? 'arm64' : arch === 'x64' ? 'amd64' : undefined;
37
+ return os !== undefined && goArch !== undefined ? { os, arch: goArch } : undefined;
38
+ };
39
+ export const archiveName = (command, version, p) => `${command}_${version}_${p.os}_${p.arch}.tar.gz`;
40
+ export const sumsName = (command, version) => `${command}_${version}_SHA256SUMS`;
41
+ /** The checksum `sha256sum` printed for one file, or undefined when the file is not listed. */
42
+ export const expectedSum = (sums, file) => {
43
+ for (const line of sums.split('\n')) {
44
+ const match = /^([0-9a-f]{64})\s+\*?(\S+)$/.exec(line.trim());
45
+ if (match?.[2] === file)
46
+ return match[1];
47
+ }
48
+ return undefined;
49
+ };
50
+ /** A release version has a download; a development build (`0.0.0-unknown`, a prerelease) does not. */
51
+ const published = (version) => /^\d+\.\d+\.\d+$/.test(version);
52
+ const isExecutable = async (path) => {
53
+ try {
54
+ await access(path, constants.X_OK);
55
+ return true;
56
+ }
57
+ catch {
58
+ return false;
59
+ }
60
+ };
61
+ const onPath = async (command, env) => {
62
+ for (const dir of (env.PATH ?? '').split(delimiter)) {
63
+ if (dir !== '' && (await isExecutable(join(dir, command))))
64
+ return true;
65
+ }
66
+ return false;
67
+ };
68
+ const run = promisify(execFile);
69
+ export const defaultInstallDeps = () => ({
70
+ env: process.env,
71
+ version: packageVersion(),
72
+ platform: platformOf(process.platform, process.arch),
73
+ cacheDir: join(process.env.APEX_HOME ?? join(homedir(), '.apex'), 'bin'),
74
+ distBase: process.env.APEX_DIST_URL ?? DIST_BASE,
75
+ fetch: async (url) => {
76
+ const res = await fetch(url);
77
+ return { ok: res.ok, status: res.status, bytes: async () => new Uint8Array(await res.arrayBuffer()) };
78
+ },
79
+ extract: async (archive, into) => {
80
+ await run('tar', ['-xzf', archive, '-C', into]);
81
+ },
82
+ notice: (message) => process.stderr.write(`${message}\n`),
83
+ });
84
+ /**
85
+ * The path to run for `command` (`apex-engine`, `apex-runner`), installing it when nothing else provides one.
86
+ *
87
+ * In order: the override in `envVar`; the command on `PATH`; this version's copy in the cache
88
+ * (`~/.apex/bin/<version>/`); and otherwise this version's archive from {@link DIST_BASE}, checked against the
89
+ * published SHA256SUMS before it is unpacked. The version is the CLI's own, so the CLI never runs an engine
90
+ * from another release. When nothing works the bare command is returned and the adapter reports it missing,
91
+ * after a notice that says why the download did not happen.
92
+ */
93
+ export const resolveBinary = async (command, envVar, deps = defaultInstallDeps()) => {
94
+ const override = deps.env[envVar];
95
+ if (override !== undefined && override !== '')
96
+ return override;
97
+ if (await onPath(command, deps.env))
98
+ return command;
99
+ const dir = join(deps.cacheDir, deps.version);
100
+ const cached = join(dir, command);
101
+ if (await isExecutable(cached))
102
+ return cached;
103
+ if (!published(deps.version))
104
+ return command;
105
+ if (deps.platform === undefined) {
106
+ deps.notice(`${command} is published for macOS and Linux on arm64 and x64 only; install it yourself and set ${envVar}`);
107
+ return command;
108
+ }
109
+ const archive = archiveName(command, deps.version, deps.platform);
110
+ const base = `${deps.distBase.replace(/\/+$/, '')}/${deps.version}`;
111
+ try {
112
+ deps.notice(`Installing ${command} ${deps.version} for ${deps.platform.os}/${deps.platform.arch} into ${dir}`);
113
+ const sums = await deps.fetch(`${base}/${sumsName(command, deps.version)}`);
114
+ if (!sums.ok)
115
+ throw new Error(`checksums: HTTP ${sums.status}`);
116
+ const want = expectedSum(new TextDecoder().decode(await sums.bytes()), archive);
117
+ if (want === undefined)
118
+ throw new Error(`${archive} is not in the published checksums`);
119
+ const body = await deps.fetch(`${base}/${archive}`);
120
+ if (!body.ok)
121
+ throw new Error(`${archive}: HTTP ${body.status}`);
122
+ const bytes = await body.bytes();
123
+ const got = createHash('sha256').update(bytes).digest('hex');
124
+ if (got !== want)
125
+ throw new Error(`${archive} does not match its published checksum`);
126
+ // Unpack inside the cache (one filesystem, so the move is a rename) and move into place, so a
127
+ // half-written install is never found as a cached one.
128
+ await mkdir(deps.cacheDir, { recursive: true });
129
+ const work = await mkdtemp(join(deps.cacheDir, `.${command}-`));
130
+ try {
131
+ const file = join(work, archive);
132
+ await writeFile(file, bytes);
133
+ await deps.extract(file, work);
134
+ await chmod(join(work, command), 0o755);
135
+ await mkdir(dir, { recursive: true });
136
+ await rename(join(work, command), cached);
137
+ }
138
+ finally {
139
+ await rm(work, { recursive: true, force: true });
140
+ }
141
+ return cached;
142
+ }
143
+ catch (error) {
144
+ deps.notice(`Could not install ${command} from ${base}/: ${error.message}`);
145
+ return command;
146
+ }
147
+ };
@@ -0,0 +1,114 @@
1
+ import { execFileSync } from 'node:child_process';
2
+ import { createHash } from 'node:crypto';
3
+ import { chmodSync, mkdirSync, mkdtempSync, readFileSync, statSync, writeFileSync } from 'node:fs';
4
+ import { tmpdir } from 'node:os';
5
+ import { join } from 'node:path';
6
+ import { describe, expect, it } from 'vitest';
7
+ import { archiveName, defaultInstallDeps, DIST_BASE, expectedSum, packageVersion, platformOf, resolveBinary, sumsName, } from './binary-install.js';
8
+ const VERSION = '0.87.0';
9
+ const PLATFORM = { os: 'darwin', arch: 'arm64' };
10
+ /** A real archive, laid out the way binary-publish packs one: the command, the licence and the notices. */
11
+ const makeArchive = (command) => {
12
+ const stage = mkdtempSync(join(tmpdir(), 'stage-'));
13
+ writeFileSync(join(stage, command), '#!/bin/sh\necho engine\n');
14
+ chmodSync(join(stage, command), 0o755);
15
+ writeFileSync(join(stage, 'LICENSE'), 'licence');
16
+ writeFileSync(join(stage, 'THIRD-PARTY-NOTICES.md'), 'notices');
17
+ const out = join(stage, 'a.tar.gz');
18
+ execFileSync('tar', ['-czf', out, '-C', stage, command, 'LICENSE', 'THIRD-PARTY-NOTICES.md']);
19
+ return new Uint8Array(readFileSync(out));
20
+ };
21
+ const sha = (b) => createHash('sha256').update(b).digest('hex');
22
+ const harness = (over = {}) => {
23
+ const notices = [];
24
+ const fetched = [];
25
+ const files = over.files ?? {};
26
+ const deps = {
27
+ ...defaultInstallDeps(),
28
+ env: { PATH: '' },
29
+ version: VERSION,
30
+ platform: PLATFORM,
31
+ cacheDir: mkdtempSync(join(tmpdir(), 'apex-bin-')),
32
+ fetch: async (url) => {
33
+ fetched.push(url);
34
+ const body = files[url];
35
+ if (body === undefined)
36
+ return { ok: false, status: 404, bytes: async () => new Uint8Array() };
37
+ return { ok: true, status: 200, bytes: async () => (typeof body === 'string' ? new TextEncoder().encode(body) : body) };
38
+ },
39
+ notice: (m) => notices.push(m),
40
+ ...over,
41
+ };
42
+ return { deps, notices, fetched };
43
+ };
44
+ const published = (command, archive, sum = sha(archive)) => ({
45
+ [`${DIST_BASE}/${VERSION}/${sumsName(command, VERSION)}`]: `${sum} ${archiveName(command, VERSION, PLATFORM)}\n`,
46
+ [`${DIST_BASE}/${VERSION}/${archiveName(command, VERSION, PLATFORM)}`]: archive,
47
+ });
48
+ describe('resolveBinary', () => {
49
+ it('prefers the override, then PATH, and downloads nothing for either', async () => {
50
+ const { deps, fetched } = harness({ env: { APEX_ENGINE_BIN: '/opt/engine', PATH: '' } });
51
+ expect(await resolveBinary('apex-engine', 'APEX_ENGINE_BIN', deps)).toBe('/opt/engine');
52
+ const bin = mkdtempSync(join(tmpdir(), 'path-'));
53
+ writeFileSync(join(bin, 'apex-engine'), '');
54
+ chmodSync(join(bin, 'apex-engine'), 0o755);
55
+ const onPath = harness({ env: { PATH: bin } });
56
+ expect(await resolveBinary('apex-engine', 'APEX_ENGINE_BIN', onPath.deps)).toBe('apex-engine');
57
+ expect([...fetched, ...onPath.fetched]).toEqual([]);
58
+ });
59
+ it('downloads this version for this platform, checks it, unpacks it executable, and reuses it', async () => {
60
+ const archive = makeArchive('apex-engine');
61
+ const { deps, fetched } = harness({ files: published('apex-engine', archive) });
62
+ const path = await resolveBinary('apex-engine', 'APEX_ENGINE_BIN', deps);
63
+ expect(path).toBe(join(deps.cacheDir, VERSION, 'apex-engine'));
64
+ expect(statSync(path).mode & 0o111).not.toBe(0);
65
+ expect(execFileSync(path).toString()).toBe('engine\n');
66
+ expect(fetched).toEqual([
67
+ `${DIST_BASE}/${VERSION}/apex-engine_${VERSION}_SHA256SUMS`,
68
+ `${DIST_BASE}/${VERSION}/apex-engine_${VERSION}_darwin_arm64.tar.gz`,
69
+ ]);
70
+ // The second call finds the cached copy and fetches nothing.
71
+ expect(await resolveBinary('apex-engine', 'APEX_ENGINE_BIN', deps)).toBe(path);
72
+ expect(fetched).toHaveLength(2);
73
+ });
74
+ it('refuses an archive that does not match its published checksum, and installs nothing', async () => {
75
+ const archive = makeArchive('apex-engine');
76
+ const { deps, notices } = harness({ files: published('apex-engine', archive, 'f'.repeat(64)) });
77
+ expect(await resolveBinary('apex-engine', 'APEX_ENGINE_BIN', deps)).toBe('apex-engine');
78
+ expect(notices.at(-1)).toMatch(/does not match its published checksum/);
79
+ expect(() => statSync(join(deps.cacheDir, VERSION, 'apex-engine'))).toThrow();
80
+ });
81
+ it('says why when the release has no download, and falls back to the bare command', async () => {
82
+ const { deps, notices } = harness();
83
+ expect(await resolveBinary('apex-runner', 'APEX_RUNNER_BIN', deps)).toBe('apex-runner');
84
+ expect(notices.at(-1)).toMatch(/Could not install apex-runner .*checksums: HTTP 404/);
85
+ });
86
+ it('never downloads for a development build or an unpublished platform', async () => {
87
+ const dev = harness({ version: '0.0.0-unknown' });
88
+ expect(await resolveBinary('apex-engine', 'APEX_ENGINE_BIN', dev.deps)).toBe('apex-engine');
89
+ expect(dev.fetched).toEqual([]);
90
+ const windows = harness({ platform: undefined });
91
+ expect(await resolveBinary('apex-engine', 'APEX_ENGINE_BIN', windows.deps)).toBe('apex-engine');
92
+ expect(windows.fetched).toEqual([]);
93
+ expect(windows.notices.at(-1)).toMatch(/macOS and Linux on arm64 and x64 only/);
94
+ });
95
+ });
96
+ describe('the names', () => {
97
+ it('maps Node platforms to the published Go targets', () => {
98
+ expect(platformOf('darwin', 'arm64')).toEqual({ os: 'darwin', arch: 'arm64' });
99
+ expect(platformOf('linux', 'x64')).toEqual({ os: 'linux', arch: 'amd64' });
100
+ expect(platformOf('win32', 'x64')).toBeUndefined();
101
+ expect(platformOf('linux', 'ia32')).toBeUndefined();
102
+ });
103
+ it('reads one line of sha256sum output, with or without the binary-mode star', () => {
104
+ const sums = `${'a'.repeat(64)} apex-engine_1.0.0_linux_amd64.tar.gz\n${'b'.repeat(64)} *apex-engine_1.0.0_darwin_arm64.tar.gz\n`;
105
+ expect(expectedSum(sums, 'apex-engine_1.0.0_darwin_arm64.tar.gz')).toBe('b'.repeat(64));
106
+ expect(expectedSum(sums, 'missing.tar.gz')).toBeUndefined();
107
+ });
108
+ it('reads the version from the nearest package.json', () => {
109
+ const root = mkdtempSync(join(tmpdir(), 'pkg-'));
110
+ mkdirSync(join(root, 'dist', 'src'), { recursive: true });
111
+ writeFileSync(join(root, 'package.json'), JSON.stringify({ version: '9.8.7' }));
112
+ expect(packageVersion(join(root, 'dist', 'src'))).toBe('9.8.7');
113
+ });
114
+ });
@@ -14,6 +14,10 @@ export class HttpError extends Error {
14
14
  this.status = status;
15
15
  }
16
16
  }
17
+ /** A named Organization's reach, as the API takes it (control-plane ADR-0084); nothing for any other scope. */
18
+ const reachBody = (target, reach) => target.kind === 'org' && target.org !== undefined && reach !== undefined
19
+ ? { visibility: reach.visibility, ...(reach.visibility === 'selected' ? { selectedRepositories: [...reach.repos] } : {}) }
20
+ : {};
17
21
  /**
18
22
  * Adapter: the control-plane REST API over the platform's `fetch`.
19
23
  *
@@ -94,8 +98,8 @@ export class HttpControlPlane {
94
98
  const body = await this.#json('GET', `${this.#scopePath(target)}/secrets`);
95
99
  return body.secrets;
96
100
  }
97
- async setSecret(target, name, value) {
98
- await this.#noContent('PUT', `${this.#scopePath(target)}/secrets/${encodeURIComponent(name)}`, { value });
101
+ async setSecret(target, name, value, reach) {
102
+ await this.#noContent('PUT', `${this.#scopePath(target)}/secrets/${encodeURIComponent(name)}`, { value, ...reachBody(target, reach) });
99
103
  }
100
104
  /**
101
105
  * `apex logout`: revoke the token this client authenticates with. A 401 means
@@ -115,14 +119,16 @@ export class HttpControlPlane {
115
119
  const body = await this.#json('GET', `${this.#scopePath(target)}/variables`);
116
120
  return body.variables;
117
121
  }
118
- async setVariable(target, name, value) {
119
- await this.#noContent('PUT', `${this.#scopePath(target)}/variables/${encodeURIComponent(name)}`, { value });
122
+ async setVariable(target, name, value, reach) {
123
+ await this.#noContent('PUT', `${this.#scopePath(target)}/variables/${encodeURIComponent(name)}`, { value, ...reachBody(target, reach) });
120
124
  }
121
125
  async deleteVariable(target, name) {
122
126
  return await this.#deleted(`${this.#scopePath(target)}/variables/${encodeURIComponent(name)}`);
123
127
  }
124
128
  #scopePath(target) {
125
- return target.kind === 'org' ? '/api/v1/org' : this.#repoPath(target.owner, target.repo);
129
+ if (target.kind === 'repo')
130
+ return this.#repoPath(target.owner, target.repo);
131
+ return target.org === undefined ? '/api/v1/org' : `/api/v1/orgs/${encodeURIComponent(target.org)}`;
126
132
  }
127
133
  async #noContent(method, path, body) {
128
134
  const response = await this.#request(method, path, body);
@@ -239,13 +245,19 @@ export class HttpControlPlane {
239
245
  return body.accepted;
240
246
  }
241
247
  async departures() {
242
- return (await this.#json('GET', '/api/v1/admin/departures')).departures;
248
+ return (await this.#json('GET', '/api/v1/console/departures')).departures;
243
249
  }
244
250
  async purgeDeparture(installationId, apply) {
245
251
  return await this.#json('POST', `/api/v1/departures/${installationId}/purge${apply ? '?apply=true' : ''}`);
246
252
  }
253
+ async repositoryDepartures() {
254
+ return (await this.#json('GET', '/api/v1/console/repository-departures')).departures;
255
+ }
256
+ async purgeRepositoryDeparture(repoId, apply) {
257
+ return await this.#json('POST', `/api/v1/repository-departures/${repoId}/purge${apply ? '?apply=true' : ''}`);
258
+ }
247
259
  async accountsBackfill(input, apply) {
248
- return await this.#json('POST', `/api/v1/admin/accounts/backfill${apply ? '?apply=true' : ''}`, input);
260
+ return await this.#json('POST', `/api/v1/console/accounts/backfill${apply ? '?apply=true' : ''}`, input);
249
261
  }
250
262
  // ── complimentary access (control-plane ADR-0056, ADR-0079): the admin API's shapes, unchanged ──
251
263
  /**
@@ -259,7 +271,7 @@ export class HttpControlPlane {
259
271
  const query = new URLSearchParams({ limit: '200' });
260
272
  if (cursor !== undefined)
261
273
  query.set('cursor', cursor);
262
- const body = await this.#json('GET', `/api/v1/admin/grants?${query.toString()}`);
274
+ const body = await this.#json('GET', `/api/v1/console/grants?${query.toString()}`);
263
275
  all.push(...body.grants);
264
276
  cursor = body.page?.nextCursor;
265
277
  if (cursor === undefined)
@@ -268,22 +280,22 @@ export class HttpControlPlane {
268
280
  return all;
269
281
  }
270
282
  async createGrant(body) {
271
- return await this.#json('POST', '/api/v1/admin/grants', body);
283
+ return await this.#json('POST', '/api/v1/console/grants', body);
272
284
  }
273
285
  async updateGrant(id, body) {
274
- return (await this.#json('PATCH', `/api/v1/admin/grants/${encodeURIComponent(id)}`, body)).grant;
286
+ return (await this.#json('PATCH', `/api/v1/console/grants/${encodeURIComponent(id)}`, body)).grant;
275
287
  }
276
288
  async revokeGrant(id) {
277
- return await this.#json('DELETE', `/api/v1/admin/grants/${encodeURIComponent(id)}`);
289
+ return await this.#json('DELETE', `/api/v1/console/grants/${encodeURIComponent(id)}`);
278
290
  }
279
291
  async listInstallations() {
280
- return (await this.#json('GET', '/api/v1/admin/installations')).installations;
292
+ return (await this.#json('GET', '/api/v1/console/installations')).installations;
281
293
  }
282
294
  async accountsUnconnected() {
283
- return (await this.#json('GET', '/api/v1/admin/accounts/unconnected')).installations;
295
+ return (await this.#json('GET', '/api/v1/console/accounts/unconnected')).installations;
284
296
  }
285
297
  async accountsConnect(input) {
286
- return await this.#json('POST', '/api/v1/admin/accounts/connect', input);
298
+ return await this.#json('POST', '/api/v1/console/accounts/connect', input);
287
299
  }
288
300
  async actionPolicy(owner) {
289
301
  return await this.#json('GET', `/api/v1/orgs/${encodeURIComponent(owner)}/action-policy`);
@@ -511,6 +523,12 @@ export class HttpControlPlane {
511
523
  throw new Error(await this.#errorMessage(response));
512
524
  return (await this.#decode(response));
513
525
  }
526
+ async getForkApproval(owner, repo) {
527
+ return await this.#json('GET', `${this.#repoPath(owner, repo)}/fork-approval`);
528
+ }
529
+ async setForkApproval(owner, repo, policy) {
530
+ return await this.#json('PUT', `${this.#repoPath(owner, repo)}/fork-approval`, { policy });
531
+ }
514
532
  async listSchedules(owner, repo) {
515
533
  return await this.#json('GET', `${this.#repoPath(owner, repo)}/schedules`);
516
534
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@apex-actions/cli",
3
- "version": "0.86.0",
3
+ "version": "0.88.0",
4
4
  "license": "SEE LICENSE IN LICENSE",
5
5
  "//files": "Without this npm ships src, test and the CI config alongside the binary a user installs.",
6
6
  "files": [