gcb-host 0.0.0 → 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (84) hide show
  1. package/CHANGELOG.md +9 -0
  2. package/bin/gcb.js +5 -0
  3. package/lib/audit.d.ts +25 -0
  4. package/lib/audit.d.ts.map +1 -0
  5. package/lib/audit.js +73 -0
  6. package/lib/audit.js.map +1 -0
  7. package/lib/broker.d.ts +61 -0
  8. package/lib/broker.d.ts.map +1 -0
  9. package/lib/broker.js +512 -0
  10. package/lib/broker.js.map +1 -0
  11. package/lib/commands/authorize.d.ts +38 -0
  12. package/lib/commands/authorize.d.ts.map +1 -0
  13. package/lib/commands/authorize.js +134 -0
  14. package/lib/commands/authorize.js.map +1 -0
  15. package/lib/commands/compose.d.ts +42 -0
  16. package/lib/commands/compose.d.ts.map +1 -0
  17. package/lib/commands/compose.js +208 -0
  18. package/lib/commands/compose.js.map +1 -0
  19. package/lib/commands/deployment.d.ts +49 -0
  20. package/lib/commands/deployment.d.ts.map +1 -0
  21. package/lib/commands/deployment.js +72 -0
  22. package/lib/commands/deployment.js.map +1 -0
  23. package/lib/commands/diagnose.d.ts +4 -0
  24. package/lib/commands/diagnose.d.ts.map +1 -0
  25. package/lib/commands/diagnose.js +213 -0
  26. package/lib/commands/diagnose.js.map +1 -0
  27. package/lib/commands/host-opener.d.ts +23 -0
  28. package/lib/commands/host-opener.d.ts.map +1 -0
  29. package/lib/commands/host-opener.js +244 -0
  30. package/lib/commands/host-opener.js.map +1 -0
  31. package/lib/commands/init.d.ts +153 -0
  32. package/lib/commands/init.d.ts.map +1 -0
  33. package/lib/commands/init.js +510 -0
  34. package/lib/commands/init.js.map +1 -0
  35. package/lib/commands/stage.d.ts +44 -0
  36. package/lib/commands/stage.d.ts.map +1 -0
  37. package/lib/commands/stage.js +165 -0
  38. package/lib/commands/stage.js.map +1 -0
  39. package/lib/config.d.ts +110 -0
  40. package/lib/config.d.ts.map +1 -0
  41. package/lib/config.js +408 -0
  42. package/lib/config.js.map +1 -0
  43. package/lib/daemon.d.ts +45 -0
  44. package/lib/daemon.d.ts.map +1 -0
  45. package/lib/daemon.js +123 -0
  46. package/lib/daemon.js.map +1 -0
  47. package/lib/device-flow.d.ts +80 -0
  48. package/lib/device-flow.d.ts.map +1 -0
  49. package/lib/device-flow.js +148 -0
  50. package/lib/device-flow.js.map +1 -0
  51. package/lib/gh.d.ts +37 -0
  52. package/lib/gh.d.ts.map +1 -0
  53. package/lib/gh.js +181 -0
  54. package/lib/gh.js.map +1 -0
  55. package/lib/http.d.ts +20 -0
  56. package/lib/http.d.ts.map +1 -0
  57. package/lib/http.js +133 -0
  58. package/lib/http.js.map +1 -0
  59. package/lib/index.d.ts +10 -0
  60. package/lib/index.d.ts.map +1 -0
  61. package/lib/index.js +52 -0
  62. package/lib/index.js.map +1 -0
  63. package/lib/policy.d.ts +60 -0
  64. package/lib/policy.d.ts.map +1 -0
  65. package/lib/policy.js +118 -0
  66. package/lib/policy.js.map +1 -0
  67. package/lib/providers/github-app.d.ts +56 -0
  68. package/lib/providers/github-app.d.ts.map +1 -0
  69. package/lib/providers/github-app.js +901 -0
  70. package/lib/providers/github-app.js.map +1 -0
  71. package/lib/providers/index.d.ts +29 -0
  72. package/lib/providers/index.d.ts.map +1 -0
  73. package/lib/providers/index.js +32 -0
  74. package/lib/providers/index.js.map +1 -0
  75. package/lib/providers/static.d.ts +18 -0
  76. package/lib/providers/static.d.ts.map +1 -0
  77. package/lib/providers/static.js +41 -0
  78. package/lib/providers/static.js.map +1 -0
  79. package/lib/proxy.d.ts +23 -0
  80. package/lib/proxy.d.ts.map +1 -0
  81. package/lib/proxy.js +57 -0
  82. package/lib/proxy.js.map +1 -0
  83. package/package.json +36 -2
  84. package/README.md +0 -1
@@ -0,0 +1,153 @@
1
+ import { type DeploymentMode } from '../config.ts';
2
+ import type { BrokerConfig, GithubAppHostConfig } from 'gcb-core';
3
+ import type { BrokerPaths } from './deployment.ts';
4
+ /** The environment `init` reasons about, injectable so the guard rails can be tested. */
5
+ export interface InitEnvironment {
6
+ /** Whether this process is inside the container. */
7
+ readonly isInsideContainer?: () => boolean;
8
+ /** The home directory whose mounts must not contain the key. */
9
+ readonly home?: string;
10
+ /** The directories the container shares, when the caller already has them. */
11
+ readonly mounts?: readonly string[];
12
+ }
13
+ /** Everything `init` needs, resolved. */
14
+ export interface InitInput {
15
+ /** The private key GitHub gave you. Required on the first run; optional afterwards. */
16
+ readonly cert?: string;
17
+ /** Where the key, config and audit log live on the host. Must be outside every container mount. */
18
+ readonly dir: string;
19
+ /** The configuration file itself. Defaults to `<dir>/broker.config.yml`. */
20
+ readonly configPath?: string;
21
+ /** Repositories to ensure are in the allowlist. */
22
+ readonly allow?: readonly string[];
23
+ /** Repositories to remove from the allowlist. */
24
+ readonly removeAllow?: readonly string[];
25
+ /** Replace the allowlist with `allow` instead of adding to it. */
26
+ readonly replaceAllow?: boolean;
27
+ /**
28
+ * Socket path a host process should use. Optional: omitted in sidecar mode, and left as-is in
29
+ * an existing host config unless you pass it. Supplying a conflicting value in sidecar mode is
30
+ * an error rather than ignored.
31
+ */
32
+ readonly hostSocketPath?: string;
33
+ /** Which deployment the recorded paths must suit. Must match an existing config. */
34
+ readonly mode?: DeploymentMode;
35
+ /** App client ID; required on the first run. */
36
+ readonly clientId?: string;
37
+ /** App ID; required on the first run. */
38
+ readonly appId?: number;
39
+ /** Permission map; left as-is unless given. */
40
+ readonly permissions?: Readonly<Record<string, string>>;
41
+ /** Audit log path override, `host` mode only. */
42
+ readonly auditPath?: string;
43
+ /** Regenerate from these flags, discarding the existing file. */
44
+ readonly force: boolean;
45
+ /** Injectable environment; defaults to the real one. */
46
+ readonly env?: InitEnvironment;
47
+ }
48
+ /** What `init` produced. */
49
+ export interface InitResult {
50
+ /** Host path of the installed key. */
51
+ readonly keyPath: string;
52
+ /** Host path of the written config. */
53
+ readonly configPath: string;
54
+ readonly config: BrokerConfig;
55
+ readonly mode: DeploymentMode;
56
+ /** The configuration that was replaced, when this was an update. */
57
+ readonly previous?: BrokerConfig;
58
+ }
59
+ /**
60
+ * The github-app block of a configuration.
61
+ *
62
+ * @param config - a validated configuration.
63
+ * @returns the block for github.com.
64
+ * @throws {Error} when there is none, which means the file is not one of ours.
65
+ */
66
+ export declare function appBlock(config: BrokerConfig): GithubAppHostConfig;
67
+ /**
68
+ * Compute the new allowlist from the existing one and the flags given.
69
+ *
70
+ * @param existing - the current entries.
71
+ * @param input - the resolved options.
72
+ * @returns the new entries, order preserved, additions deduplicated case-insensitively.
73
+ * @throws {Error} when a given entry is malformed.
74
+ */
75
+ export declare function mergeAllowList(existing: readonly string[], input: InitInput): string[];
76
+ /**
77
+ * Build a fresh configuration from the flags.
78
+ *
79
+ * @param input - the resolved options.
80
+ * @returns the validated configuration.
81
+ * @throws {Error} when the configuration would be rejected by the daemon.
82
+ */
83
+ export declare function buildBrokerConfig(input: InitInput): BrokerConfig;
84
+ /**
85
+ * Merge the flags into an existing configuration, changing only what was asked for.
86
+ *
87
+ * The paths come from `paths` rather than from `existing`, because the caller has already decided
88
+ * whether to carry them over: on a deployment switch the old ones belong to the other layout and
89
+ * must not survive. (Carrying the sidecar's auditPath into a host config, for instance, points the
90
+ * daemon at `/var/log/git-cred-broker/` on a host that has no such directory.)
91
+ *
92
+ * @param existing - the configuration on disk.
93
+ * @param input - the resolved options.
94
+ * @param paths - the paths the broker will resolve.
95
+ * @returns the validated configuration to write.
96
+ * @throws {Error} when the result would be rejected by the daemon.
97
+ */
98
+ export declare function mergeBrokerConfig(existing: BrokerConfig, input: InitInput, paths: BrokerPaths): BrokerConfig;
99
+ /**
100
+ * Read and validate an existing configuration.
101
+ *
102
+ * @param path - the config file.
103
+ * @returns the configuration, or null when the file does not exist.
104
+ * @throws {Error} when the file exists but is not a valid configuration.
105
+ */
106
+ /** One path whose value the user explicitly changed. */
107
+ export interface ConfigChange {
108
+ readonly path: readonly (string | number)[];
109
+ readonly value: unknown;
110
+ }
111
+ /**
112
+ * Install the key and create or update the configuration.
113
+ *
114
+ * @param input - the resolved options.
115
+ * @returns where things landed.
116
+ * @throws {Error} for any guard-rail violation.
117
+ */
118
+ export declare function performInit(input: InitInput): InitResult;
119
+ /** Usage text for `init`. */
120
+ export declare const INIT_USAGE: string;
121
+ /**
122
+ * Parse `--permissions contents=write,pull_requests=write`.
123
+ *
124
+ * @param raw - the raw flag value, if any.
125
+ * @returns the permission map, or undefined when the flag was not given.
126
+ * @throws {Error} when an entry is not `name=level`.
127
+ */
128
+ export declare function parsePermissions(raw: string | undefined): Record<string, string> | undefined;
129
+ /**
130
+ * Resolve which deployment directory and configuration file an init invokes.
131
+ *
132
+ * Without --dir, the config is the file the broker reads: --config, else GIT_BROKER_CONFIG, else
133
+ * the default name. With --dir, the default name inside that directory is used.
134
+ *
135
+ * @param args - the two path flags.
136
+ * @param env - the environment, injectable for tests.
137
+ * @returns the directory and, when explicit, the config path.
138
+ */
139
+ export declare function resolveInitSelection(args: {
140
+ readonly config?: string;
141
+ readonly dir?: string;
142
+ }, env?: NodeJS.ProcessEnv): {
143
+ readonly dir: string;
144
+ readonly configPath: string | undefined;
145
+ };
146
+ /**
147
+ * Run the `init` command.
148
+ *
149
+ * @param argv - arguments after the command name.
150
+ * @returns the process exit code.
151
+ */
152
+ export declare function runInit(argv: readonly string[]): Promise<number>;
153
+ //# sourceMappingURL=init.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"init.d.ts","sourceRoot":"","sources":["../../src/commands/init.ts"],"names":[],"mappings":"AAoBA,OAAO,EAOL,KAAK,cAAc,EACpB,MAAM,cAAc,CAAC;AAEtB,OAAO,KAAK,EAAE,YAAY,EAAE,mBAAmB,EAAE,MAAM,UAAU,CAAC;AAQlE,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,iBAAiB,CAAC;AAGnD,yFAAyF;AACzF,MAAM,WAAW,eAAe;IAC9B,oDAAoD;IACpD,QAAQ,CAAC,iBAAiB,CAAC,EAAE,MAAM,OAAO,CAAC;IAC3C,gEAAgE;IAChE,QAAQ,CAAC,IAAI,CAAC,EAAE,MAAM,CAAC;IACvB,8EAA8E;IAC9E,QAAQ,CAAC,MAAM,CAAC,EAAE,SAAS,MAAM,EAAE,CAAC;CACrC;AAED,yCAAyC;AACzC,MAAM,WAAW,SAAS;IACxB,uFAAuF;IACvF,QAAQ,CAAC,IAAI,CAAC,EAAE,MAAM,CAAC;IACvB,mGAAmG;IACnG,QAAQ,CAAC,GAAG,EAAE,MAAM,CAAC;IACrB,4EAA4E;IAC5E,QAAQ,CAAC,UAAU,CAAC,EAAE,MAAM,CAAC;IAC7B,mDAAmD;IACnD,QAAQ,CAAC,KAAK,CAAC,EAAE,SAAS,MAAM,EAAE,CAAC;IACnC,iDAAiD;IACjD,QAAQ,CAAC,WAAW,CAAC,EAAE,SAAS,MAAM,EAAE,CAAC;IACzC,kEAAkE;IAClE,QAAQ,CAAC,YAAY,CAAC,EAAE,OAAO,CAAC;IAChC;;;;OAIG;IACH,QAAQ,CAAC,cAAc,CAAC,EAAE,MAAM,CAAC;IACjC,oFAAoF;IACpF,QAAQ,CAAC,IAAI,CAAC,EAAE,cAAc,CAAC;IAC/B,gDAAgD;IAChD,QAAQ,CAAC,QAAQ,CAAC,EAAE,MAAM,CAAC;IAC3B,yCAAyC;IACzC,QAAQ,CAAC,KAAK,CAAC,EAAE,MAAM,CAAC;IACxB,+CAA+C;IAC/C,QAAQ,CAAC,WAAW,CAAC,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC,CAAC;IACxD,iDAAiD;IACjD,QAAQ,CAAC,SAAS,CAAC,EAAE,MAAM,CAAC;IAC5B,iEAAiE;IACjE,QAAQ,CAAC,KAAK,EAAE,OAAO,CAAC;IACxB,wDAAwD;IACxD,QAAQ,CAAC,GAAG,CAAC,EAAE,eAAe,CAAC;CAChC;AAED,4BAA4B;AAC5B,MAAM,WAAW,UAAU;IACzB,sCAAsC;IACtC,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAC;IACzB,uCAAuC;IACvC,QAAQ,CAAC,UAAU,EAAE,MAAM,CAAC;IAC5B,QAAQ,CAAC,MAAM,EAAE,YAAY,CAAC;IAC9B,QAAQ,CAAC,IAAI,EAAE,cAAc,CAAC;IAC9B,oEAAoE;IACpE,QAAQ,CAAC,QAAQ,CAAC,EAAE,YAAY,CAAC;CAClC;AAED;;;;;;GAMG;AACH,wBAAgB,QAAQ,CAAC,MAAM,EAAE,YAAY,GAAG,mBAAmB,CAMlE;AAED;;;;;;;GAOG;AACH,wBAAgB,cAAc,CAAC,QAAQ,EAAE,SAAS,MAAM,EAAE,EAAE,KAAK,EAAE,SAAS,GAAG,MAAM,EAAE,CA4BtF;AAED;;;;;;GAMG;AACH,wBAAgB,iBAAiB,CAAC,KAAK,EAAE,SAAS,GAAG,YAAY,CAgBhE;AAED;;;;;;;;;;;;;GAaG;AACH,wBAAgB,iBAAiB,CAAC,QAAQ,EAAE,YAAY,EAAE,KAAK,EAAE,SAAS,EAAE,KAAK,EAAE,WAAW,GAAG,YAAY,CAiB5G;AAED;;;;;;GAMG;AACH,wDAAwD;AACxD,MAAM,WAAW,YAAY;IAC3B,QAAQ,CAAC,IAAI,EAAE,SAAS,CAAC,MAAM,GAAG,MAAM,CAAC,EAAE,CAAC;IAC5C,QAAQ,CAAC,KAAK,EAAE,OAAO,CAAC;CACzB;AAwCD;;;;;;GAMG;AACH,wBAAgB,WAAW,CAAC,KAAK,EAAE,SAAS,GAAG,UAAU,CAsIxD;AAgBD,6BAA6B;AAC7B,eAAO,MAAM,UAAU,QAkCtB,CAAC;AAEF;;;;;;GAMG;AACH,wBAAgB,gBAAgB,CAAC,GAAG,EAAE,MAAM,GAAG,SAAS,GAAG,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,GAAG,SAAS,CAW5F;AAoCD;;;;;;;;;GASG;AACH,wBAAgB,oBAAoB,CAClC,IAAI,EAAE;IAAE,QAAQ,CAAC,MAAM,CAAC,EAAE,MAAM,CAAC;IAAC,QAAQ,CAAC,GAAG,CAAC,EAAE,MAAM,CAAA;CAAE,EACzD,GAAG,GAAE,MAAM,CAAC,UAAwB,GACnC;IAAE,QAAQ,CAAC,GAAG,EAAE,MAAM,CAAC;IAAC,QAAQ,CAAC,UAAU,EAAE,MAAM,GAAG,SAAS,CAAA;CAAE,CASnE;AAED;;;;;GAKG;AACH,wBAAsB,OAAO,CAAC,IAAI,EAAE,SAAS,MAAM,EAAE,GAAG,OAAO,CAAC,MAAM,CAAC,CAsGtE"}
@@ -0,0 +1,510 @@
1
+ /**
2
+ * `init` — create or update the broker configuration, on the host.
3
+ *
4
+ * The configuration file is the source of truth: the daemon only ever reads it, it is meant to
5
+ * be reviewed and kept, and it can be edited by hand. This command therefore **merges** into an
6
+ * existing file instead of rewriting it: only the flags you pass change anything, and anything
7
+ * else — including edits you made yourself — is preserved.
8
+ *
9
+ * That matters most for the allowlist, the thing people actually iterate on. Adding one
10
+ * repository used to mean re-supplying every flag, and `--force` then silently *replaced* the
11
+ * list, so adding `b/two` could quietly drop `a/one`. Now `--allow b/two` adds it and nothing
12
+ * else moves.
13
+ *
14
+ * `--force` still exists, for regenerating from flags when the file is beyond editing.
15
+ */
16
+ import fs from 'node:fs';
17
+ import path from 'node:path';
18
+ import { isDeepStrictEqual } from 'node:util';
19
+ import { parseDocument, stringify } from 'yaml';
20
+ import { deploymentDir, inferMode, loadConfig, resolveConfigPath, SIDECAR, validateConfig, } from '../config.js';
21
+ import { parseAllowEntry } from '../policy.js';
22
+ import { brokerPaths, DEPLOYMENT_MODES, hostArtifacts, HOST_SOCKET_PATH_DEFAULT, requireFixedInSidecar, } from './deployment.js';
23
+ import { fail, insideMountedPath, isInsideContainer, parseArgs, say, writeSecretFile } from 'gcb-core';
24
+ /**
25
+ * The github-app block of a configuration.
26
+ *
27
+ * @param config - a validated configuration.
28
+ * @returns the block for github.com.
29
+ * @throws {Error} when there is none, which means the file is not one of ours.
30
+ */
31
+ export function appBlock(config) {
32
+ const block = config.hosts['github.com'];
33
+ if (block?.provider !== 'github-app') {
34
+ throw new Error('the configuration has no github-app block for github.com');
35
+ }
36
+ return block;
37
+ }
38
+ /**
39
+ * Compute the new allowlist from the existing one and the flags given.
40
+ *
41
+ * @param existing - the current entries.
42
+ * @param input - the resolved options.
43
+ * @returns the new entries, order preserved, additions deduplicated case-insensitively.
44
+ * @throws {Error} when a given entry is malformed.
45
+ */
46
+ export function mergeAllowList(existing, input) {
47
+ const additions = input.allow ?? [];
48
+ for (const entry of [...additions, ...(input.removeAllow ?? [])]) {
49
+ if (!parseAllowEntry(entry)) {
50
+ throw new Error(`--allow ${JSON.stringify(entry)} is not a valid "owner/repo" pattern (a whole segment may be "*")`);
51
+ }
52
+ }
53
+ const canonical = (entry) => entry.trim().toLowerCase();
54
+ const removed = new Set((input.removeAllow ?? []).map(canonical));
55
+ const kept = input.replaceAllow ? [] : existing.filter((entry) => !removed.has(canonical(entry)));
56
+ const seen = new Set(kept.map(canonical));
57
+ const result = [...kept];
58
+ for (const entry of additions) {
59
+ const key = canonical(entry);
60
+ if (seen.has(key))
61
+ continue;
62
+ seen.add(key);
63
+ result.push(entry.trim());
64
+ }
65
+ if (result.length === 0) {
66
+ throw new Error('that would leave the allowlist empty; the broker is default-deny, so there would be nothing it could authorize');
67
+ }
68
+ return result;
69
+ }
70
+ /**
71
+ * Build a fresh configuration from the flags.
72
+ *
73
+ * @param input - the resolved options.
74
+ * @returns the validated configuration.
75
+ * @throws {Error} when the configuration would be rejected by the daemon.
76
+ */
77
+ export function buildBrokerConfig(input) {
78
+ const paths = brokerPaths(input.mode ?? 'host', input.dir, input.hostSocketPath);
79
+ return validateConfig({
80
+ socketPath: paths.socketPath,
81
+ auditPath: input.auditPath ?? paths.auditPath,
82
+ hosts: {
83
+ 'github.com': {
84
+ provider: 'github-app',
85
+ allow: [...(input.allow ?? [])],
86
+ clientId: input.clientId,
87
+ appId: input.appId,
88
+ privateKeyPath: paths.privateKeyPath,
89
+ permissions: input.permissions,
90
+ },
91
+ },
92
+ });
93
+ }
94
+ /**
95
+ * Merge the flags into an existing configuration, changing only what was asked for.
96
+ *
97
+ * The paths come from `paths` rather than from `existing`, because the caller has already decided
98
+ * whether to carry them over: on a deployment switch the old ones belong to the other layout and
99
+ * must not survive. (Carrying the sidecar's auditPath into a host config, for instance, points the
100
+ * daemon at `/var/log/git-cred-broker/` on a host that has no such directory.)
101
+ *
102
+ * @param existing - the configuration on disk.
103
+ * @param input - the resolved options.
104
+ * @param paths - the paths the broker will resolve.
105
+ * @returns the validated configuration to write.
106
+ * @throws {Error} when the result would be rejected by the daemon.
107
+ */
108
+ export function mergeBrokerConfig(existing, input, paths) {
109
+ const block = appBlock(existing);
110
+ return validateConfig({
111
+ socketPath: paths.socketPath,
112
+ auditPath: paths.auditPath,
113
+ hosts: {
114
+ ...existing.hosts,
115
+ 'github.com': {
116
+ provider: 'github-app',
117
+ allow: mergeAllowList(block.allow, input),
118
+ clientId: input.clientId ?? block.clientId,
119
+ appId: input.appId ?? block.appId,
120
+ privateKeyPath: paths.privateKeyPath,
121
+ permissions: input.permissions ?? block.permissions,
122
+ },
123
+ },
124
+ });
125
+ }
126
+ /**
127
+ * Write a configuration. A fresh file is serialized from the merged object; an existing file is
128
+ * edited only at the paths the user changed, so comments, other hosts and unknown keys survive.
129
+ *
130
+ * @param configPath - the destination.
131
+ * @param config - the configuration to write when the file is new or forced.
132
+ * @param force - replace the file instead of editing it.
133
+ * @param changes - explicit edits for the existing-document path.
134
+ */
135
+ function writeConfig(configPath, config, force, changes) {
136
+ if (force || !fs.existsSync(configPath)) {
137
+ writeSecretFile(configPath, `# Broker configuration for gcb. See README.md.\n${stringify(config, { lineWidth: 0 })}`);
138
+ return;
139
+ }
140
+ if (changes.length === 0)
141
+ return;
142
+ const document = parseDocument(fs.readFileSync(configPath, 'utf8'), {
143
+ schema: 'core',
144
+ uniqueKeys: true,
145
+ prettyErrors: true,
146
+ });
147
+ if (document.errors.length > 0) {
148
+ throw new Error(`cannot update ${configPath}: ${document.errors.map((error) => error.message).join('; ')}`);
149
+ }
150
+ for (const change of changes)
151
+ document.setIn(change.path, change.value);
152
+ const text = document.toString();
153
+ writeSecretFile(configPath, text.endsWith('\n') ? text : `${text}\n`);
154
+ }
155
+ function readExistingConfig(path) {
156
+ if (!fs.existsSync(path))
157
+ return null;
158
+ return loadConfig(path);
159
+ }
160
+ /**
161
+ * Install the key and create or update the configuration.
162
+ *
163
+ * @param input - the resolved options.
164
+ * @returns where things landed.
165
+ * @throws {Error} for any guard-rail violation.
166
+ */
167
+ export function performInit(input) {
168
+ if (input.mode !== undefined && !DEPLOYMENT_MODES.includes(input.mode)) {
169
+ throw new Error(`--mode must be one of ${DEPLOYMENT_MODES.join(', ')}; got ${input.mode}`);
170
+ }
171
+ const insideContainer = (input.env?.isInsideContainer ?? isInsideContainer)();
172
+ if (insideContainer) {
173
+ throw new Error('this looks like the agent container; the broker must run outside it, on the host');
174
+ }
175
+ const home = input.env?.home ?? (process.env['HOME'] ?? '');
176
+ const mounted = insideMountedPath(input.dir, home, input.env?.mounts);
177
+ if (mounted) {
178
+ throw new Error(`${input.dir} is inside ${mounted}, which is mounted into the container; the private key must live outside every mount`);
179
+ }
180
+ const artifacts = hostArtifacts(input.dir);
181
+ const configPath = input.configPath ?? artifacts.configPath;
182
+ // --config may point into a directory that does not exist yet; the deployment directory is
183
+ // created below either way, so do it once here.
184
+ fs.mkdirSync(deploymentDir(configPath), { recursive: true });
185
+ const previous = input.force ? null : readExistingConfig(configPath);
186
+ // ---------------------------------------------------------------- update in place
187
+ if (previous) {
188
+ const existingMode = inferMode(previous);
189
+ // Switching deployment is allowed, but only when asked for by name. The recorded paths are
190
+ // what decide whether a broker can find its own key, so this must never happen implicitly.
191
+ const mode = input.mode ?? existingMode;
192
+ const switching = mode !== existingMode;
193
+ if (switching && mode === 'host' && input.hostSocketPath === undefined) {
194
+ throw new Error(`${configPath} records the sidecar's socket path (${previous.socketPath}), which a host process cannot bind; ` +
195
+ 'pass --socket-path <the path the host process binds and the pusher reaches>');
196
+ }
197
+ requireFixedInSidecar('--socket-path', input.hostSocketPath, SIDECAR.socketPath, mode);
198
+ requireFixedInSidecar('--audit-path', input.auditPath, SIDECAR.auditPath, mode);
199
+ // The key the *host* must have is always <dir>/app.pem. In sidecar mode the config records a
200
+ // different path, because that is where the sidecar sees the same file — checking the
201
+ // recorded path here would fail on every update.
202
+ if (input.cert !== undefined) {
203
+ writeSecretFile(artifacts.keyPath, readPrivateKey(input.cert));
204
+ }
205
+ else if (!fs.existsSync(artifacts.keyPath)) {
206
+ throw new Error(`${artifacts.keyPath} is missing; pass --cert to install the key`);
207
+ }
208
+ let paths;
209
+ if (mode === 'sidecar') {
210
+ paths = brokerPaths('sidecar', input.dir, '');
211
+ }
212
+ else {
213
+ // Staying in host mode: keep whatever this deployment chose. Switching *to* host mode: the
214
+ // old socket and audit paths are the sidecar's container-side ones, so carrying them over
215
+ // would produce a config that looks fine and cannot work.
216
+ const carried = switching ? undefined : previous;
217
+ paths = brokerPaths('host', input.dir, input.hostSocketPath ?? carried?.socketPath ?? HOST_SOCKET_PATH_DEFAULT);
218
+ const carriedAudit = carried?.auditPath ?? undefined;
219
+ if (input.auditPath !== undefined)
220
+ paths = { ...paths, auditPath: input.auditPath };
221
+ else if (carriedAudit !== undefined)
222
+ paths = { ...paths, auditPath: carriedAudit };
223
+ }
224
+ const config = mergeBrokerConfig(previous, input, paths);
225
+ const block = appBlock(previous);
226
+ const mergedBlock = appBlock(config);
227
+ const allowChanged = (input.allow?.length ?? 0) > 0 || (input.removeAllow?.length ?? 0) > 0 || input.replaceAllow === true;
228
+ const changes = [];
229
+ if (paths.socketPath !== previous.socketPath)
230
+ changes.push({ path: ['socketPath'], value: paths.socketPath });
231
+ if (paths.auditPath !== previous.auditPath)
232
+ changes.push({ path: ['auditPath'], value: paths.auditPath });
233
+ if (paths.privateKeyPath !== block.privateKeyPath) {
234
+ changes.push({ path: ['hosts', 'github.com', 'privateKeyPath'], value: paths.privateKeyPath });
235
+ }
236
+ if (allowChanged && !isDeepStrictEqual(mergedBlock.allow, block.allow)) {
237
+ changes.push({ path: ['hosts', 'github.com', 'allow'], value: mergedBlock.allow });
238
+ }
239
+ if (input.clientId !== undefined && input.clientId !== block.clientId) {
240
+ changes.push({ path: ['hosts', 'github.com', 'clientId'], value: input.clientId });
241
+ }
242
+ if (input.appId !== undefined && input.appId !== block.appId) {
243
+ changes.push({ path: ['hosts', 'github.com', 'appId'], value: input.appId });
244
+ }
245
+ if (input.permissions !== undefined && !isDeepStrictEqual(input.permissions, block.permissions)) {
246
+ changes.push({ path: ['hosts', 'github.com', 'permissions'], value: input.permissions });
247
+ }
248
+ fs.mkdirSync(artifacts.auditDir, { recursive: true });
249
+ writeConfig(configPath, config, input.force, changes);
250
+ return { keyPath: artifacts.keyPath, configPath, config, mode, previous };
251
+ }
252
+ // ---------------------------------------------------------------- create (or regenerate)
253
+ const mode = input.mode ?? 'host';
254
+ const allow = input.allow ?? [];
255
+ if (allow.length === 0) {
256
+ throw new Error('at least one --allow <owner/repo> is required when creating the configuration (the broker is default-deny)');
257
+ }
258
+ for (const entry of allow) {
259
+ if (!parseAllowEntry(entry)) {
260
+ throw new Error(`--allow ${JSON.stringify(entry)} is not a valid "owner/repo" pattern (a whole segment may be "*")`);
261
+ }
262
+ }
263
+ // Captured into locals: narrowing of `input.x` is invalidated by the calls in between, so
264
+ // reading the property again afterwards is `string | undefined` to the compiler.
265
+ const certPath = input.cert;
266
+ const clientId = input.clientId;
267
+ const appId = input.appId;
268
+ if (certPath === undefined)
269
+ throw new Error('--cert is required when creating the configuration');
270
+ if (clientId === undefined || clientId === '') {
271
+ throw new Error('--client-id is required when creating the configuration');
272
+ }
273
+ if (appId === undefined || appId <= 0) {
274
+ throw new Error('--app-id is required when creating the configuration');
275
+ }
276
+ requireFixedInSidecar('--socket-path', input.hostSocketPath, SIDECAR.socketPath, mode);
277
+ requireFixedInSidecar('--audit-path', input.auditPath, SIDECAR.auditPath, mode);
278
+ const pem = readPrivateKey(certPath);
279
+ const config = buildBrokerConfig({ ...input, mode, cert: certPath, clientId, appId });
280
+ writeSecretFile(artifacts.keyPath, pem);
281
+ fs.mkdirSync(artifacts.auditDir, { recursive: true });
282
+ writeConfig(configPath, config, input.force, []);
283
+ return { keyPath: artifacts.keyPath, configPath, config, mode };
284
+ }
285
+ /**
286
+ * Read and sanity-check a PEM private key.
287
+ *
288
+ * @param cert - the path GitHub gave you.
289
+ * @returns the file contents.
290
+ * @throws {Error} when it is missing or is not a PEM private key.
291
+ */
292
+ function readPrivateKey(cert) {
293
+ if (!fs.existsSync(cert))
294
+ throw new Error(`no such file: ${cert}`);
295
+ const pem = fs.readFileSync(cert, 'utf8');
296
+ if (!pem.includes('PRIVATE KEY'))
297
+ throw new Error(`${cert} does not look like a PEM private key`);
298
+ return pem;
299
+ }
300
+ /** Usage text for `init`. */
301
+ export const INIT_USAGE = `Usage: gcb init [options]
302
+
303
+ Create or update the broker configuration. Run this on the HOST.
304
+
305
+ The configuration file is the source of truth. Re-running init updates it in place and changes
306
+ only what you pass, so hand edits survive and the allowlist is never silently replaced.
307
+
308
+ First run:
309
+ --cert <path> GitHub App private key (.pem) [required]
310
+ --allow <owner/repo> Repository to authorize; repeatable or comma-separated [required]
311
+ --client-id <id> App client ID (preferred JWT issuer) [required]
312
+ --app-id <id> App ID (fallback issuer) [required]
313
+
314
+ Every run:
315
+ --allow <owner/repo> Add to the allowlist (deduplicated); repeatable or comma-separated
316
+ --remove-allow <r> Remove from the allowlist; repeatable or comma-separated
317
+ --replace-allow Replace the whole allowlist with --allow, instead of adding
318
+ --cert <path> Replace the installed key
319
+ --client-id <id> Change the client ID
320
+ --app-id <id> Change the App ID
321
+ --permissions <list> name=level pairs, e.g. contents=write,pull_requests=write
322
+ --dir <path> Where the key, config and audit log live (default: the config's directory)
323
+ --config <path> Configuration file (default: $GIT_BROKER_CONFIG, else ./broker.config.yml)
324
+ --mode <host|sidecar> Which deployment the paths must suit (default: host; must match an
325
+ existing config, because it decides where the broker looks for things)
326
+ --socket-path <path> Socket path a host process uses (default: /run/git-cred-broker/broker.sock)
327
+ --audit-path <path> Audit log path override (host mode only)
328
+ --force Regenerate from these flags, discarding the existing file
329
+ -h, --help Show this help
330
+
331
+ --mode matters because the paths inside the configuration are resolved by the broker process,
332
+ not by this one. In --mode sidecar they are fixed by the compose file (socketPath
333
+ ${SIDECAR.socketPath}, privateKeyPath ${SIDECAR.keyPath}, auditPath ${SIDECAR.auditPath}),
334
+ so a different --socket-path or --audit-path is rejected rather than silently recorded.
335
+ `;
336
+ /**
337
+ * Parse `--permissions contents=write,pull_requests=write`.
338
+ *
339
+ * @param raw - the raw flag value, if any.
340
+ * @returns the permission map, or undefined when the flag was not given.
341
+ * @throws {Error} when an entry is not `name=level`.
342
+ */
343
+ export function parsePermissions(raw) {
344
+ if (raw === undefined)
345
+ return undefined;
346
+ const out = {};
347
+ for (const pair of raw.split(',')) {
348
+ const trimmed = pair.trim();
349
+ if (!trimmed)
350
+ continue;
351
+ const equals = trimmed.indexOf('=');
352
+ if (equals <= 0)
353
+ throw new Error(`--permissions expects name=level pairs, got ${JSON.stringify(trimmed)}`);
354
+ out[trimmed.slice(0, equals)] = trimmed.slice(equals + 1);
355
+ }
356
+ return Object.keys(out).length > 0 ? out : undefined;
357
+ }
358
+ /**
359
+ * Render the permissions the broker will actually request, in a stable order.
360
+ *
361
+ * An absent map means the provider's default, which is `contents=write` — worth printing, because
362
+ * "I did not pass anything" and "I passed something that did not stick" otherwise look identical.
363
+ *
364
+ * @param permissions - the configured map, if any.
365
+ * @returns a `name=level` list.
366
+ */
367
+ function formatPermissions(permissions) {
368
+ const effective = permissions ?? { contents: 'write' };
369
+ return (Object.entries(effective)
370
+ .sort(([left], [right]) => left.localeCompare(right))
371
+ .map(([name, level]) => `${name}=${level}`)
372
+ .join(' ') || '(none)');
373
+ }
374
+ /**
375
+ * Split a repeatable, comma-separated flag.
376
+ *
377
+ * @param args - the parsed arguments.
378
+ * @param name - the flag name.
379
+ * @returns the entries.
380
+ */
381
+ function list(args, name) {
382
+ return args
383
+ .values(name)
384
+ .flatMap((value) => value.split(','))
385
+ .map((entry) => entry.trim())
386
+ .filter((entry) => entry.length > 0);
387
+ }
388
+ /**
389
+ * Resolve which deployment directory and configuration file an init invokes.
390
+ *
391
+ * Without --dir, the config is the file the broker reads: --config, else GIT_BROKER_CONFIG, else
392
+ * the default name. With --dir, the default name inside that directory is used.
393
+ *
394
+ * @param args - the two path flags.
395
+ * @param env - the environment, injectable for tests.
396
+ * @returns the directory and, when explicit, the config path.
397
+ */
398
+ export function resolveInitSelection(args, env = process.env) {
399
+ const dir = path.resolve(args.dir ?? deploymentDir(resolveConfigPath(args.config, env)));
400
+ const configPath = args.config !== undefined
401
+ ? path.resolve(args.config)
402
+ : args.dir === undefined
403
+ ? resolveConfigPath(undefined, env)
404
+ : undefined;
405
+ return { dir, configPath };
406
+ }
407
+ /**
408
+ * Run the `init` command.
409
+ *
410
+ * @param argv - arguments after the command name.
411
+ * @returns the process exit code.
412
+ */
413
+ export async function runInit(argv) {
414
+ const args = parseArgs(argv, { booleans: ['force', 'replace-allow', 'help'] });
415
+ if (args.has('help') || args.has('h')) {
416
+ say(INIT_USAGE);
417
+ return 0;
418
+ }
419
+ const clientId = args.value('client-id');
420
+ const appIdRaw = args.value('app-id');
421
+ const appId = appIdRaw === undefined ? undefined : Number(appIdRaw);
422
+ if (appId !== undefined && (!Number.isInteger(appId) || appId <= 0)) {
423
+ fail('--app-id must be a positive integer');
424
+ }
425
+ const mode = args.value('mode');
426
+ if (mode !== undefined && !DEPLOYMENT_MODES.includes(mode)) {
427
+ fail(`--mode must be one of ${DEPLOYMENT_MODES.join(', ')}`);
428
+ }
429
+ const selection = resolveInitSelection({ config: args.value('config'), dir: args.value('dir') });
430
+ try {
431
+ const result = performInit({
432
+ cert: args.value('cert'),
433
+ dir: selection.dir,
434
+ configPath: selection.configPath,
435
+ allow: list(args, 'allow'),
436
+ removeAllow: list(args, 'remove-allow'),
437
+ replaceAllow: args.has('replace-allow'),
438
+ hostSocketPath: args.value('socket-path'),
439
+ mode: mode,
440
+ clientId,
441
+ appId,
442
+ permissions: parsePermissions(args.value('permissions')),
443
+ auditPath: args.value('audit-path'),
444
+ force: args.has('force'),
445
+ });
446
+ const verb = result.previous ? 'updated' : 'created';
447
+ const wasMode = result.previous ? inferMode(result.previous) : undefined;
448
+ const modeLabel = wasMode !== undefined && wasMode !== result.mode ? `${wasMode} -> ${result.mode}` : result.mode;
449
+ say(`mode : ${modeLabel}`);
450
+ say(`${verb.padEnd(15)}: ${result.configPath} (mode 600, validation passed)`);
451
+ say(`installed key : ${result.keyPath} (mode 600)`);
452
+ const block = appBlock(result.config);
453
+ if (result.previous) {
454
+ const beforeBlock = appBlock(result.previous);
455
+ say('');
456
+ say(`allowlist : ${beforeBlock.allow.join(' ')}`);
457
+ say(` -> ${block.allow.join(' ')}`);
458
+ }
459
+ else {
460
+ say(`allowlist : ${block.allow.join(' ')}`);
461
+ }
462
+ // Always, not only when it changed: without this, "did --permissions take effect?" could only be
463
+ // answered by re-reading the file, and a run that did nothing looks exactly like one that worked.
464
+ say(`permissions : ${formatPermissions(block.permissions)}${block.permissions ? '' : ' (the default)'}`);
465
+ say('');
466
+ say('paths inside the configuration, from the broker\'s point of view:');
467
+ say(` socketPath : ${result.config.socketPath}`);
468
+ say(` privateKeyPath : ${block.privateKeyPath}${block.privateKeyPath === result.keyPath ? '' : ` (= ${result.keyPath} on the host)`}`);
469
+ say(` auditPath : ${result.config.auditPath}`);
470
+ say('');
471
+ if (result.previous) {
472
+ say('Restart the broker to pick this up:');
473
+ if (result.mode === 'sidecar') {
474
+ say(' docker compose -f docker-compose.broker.yml up -d # sidecar');
475
+ }
476
+ else {
477
+ say(` gcb daemon --config ${result.configPath} # host process`);
478
+ }
479
+ if (wasMode !== undefined && wasMode !== result.mode) {
480
+ say('');
481
+ say(`NOTE: the recorded paths changed from the ${wasMode} layout to the ${result.mode} layout.`);
482
+ say(result.mode === 'host'
483
+ ? ' Stop the sidecar and start the host process; the socket file is the same one.'
484
+ : ' Stop the host process and start the sidecar; the socket file is the same one.');
485
+ }
486
+ }
487
+ else {
488
+ say('Two things only you can do:');
489
+ say(' 1. Install the app on the account that owns those repositories, granting access to just them.');
490
+ say(' A private app owned by an organisation can only be installed on that organisation.');
491
+ say(' 2. Confirm the app grants every permission requested above; the broker checks on first use.');
492
+ say('');
493
+ if (result.mode === 'sidecar') {
494
+ say('Then start the sidecar, which mounts those paths exactly as recorded:');
495
+ say(` gcb compose --dir ${path.dirname(result.keyPath)} --config ${result.configPath} --socket-dir <a directory the pusher mounts> \\`);
496
+ say(' > docker-compose.broker.yml && docker compose -f docker-compose.broker.yml up -d');
497
+ }
498
+ else {
499
+ say('Then start the broker as a host process:');
500
+ say(` gcb daemon --config ${result.configPath} --check # validate, bind nothing`);
501
+ say(` gcb daemon --config ${result.configPath}`);
502
+ }
503
+ }
504
+ return 0;
505
+ }
506
+ catch (error) {
507
+ fail(error.message);
508
+ }
509
+ }
510
+ //# sourceMappingURL=init.js.map