@theholocron/holocron-plugin-github 5.0.0-alpha.26 → 5.0.0-alpha.260

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.
package/README.md CHANGED
@@ -45,7 +45,7 @@ holocron auth set github.release ghp_xxx # semantic-release
45
45
  holocron auth set github.admin ghp_xxx # setup, secrets, environments
46
46
  ```
47
47
 
48
- See [docs/tokens.md](../../docs/tokens.md) for full scope requirements and CI setup.
48
+ See the [token reference](https://docs.theholocron.dev/holocron/tokens/) for full scope requirements and CI setup.
49
49
 
50
50
  ## Config
51
51
 
package/dist/index.d.mts CHANGED
@@ -1,19 +1,18 @@
1
+ import { Auth, AuthError, Ci, CiRun, CiRunFilter, Environment, Environments, Issue, IssueSearchFilter, Issues, LabelDef, LifecycleResult, LifecycleSlot, PagesConfig, PullRequest, RepoRef, RepoSettings, ResolveTokenInput, Ruleset, SecretScope, Secrets, Source, SourceFile, SourceTeamMember, TeamEntry, TeamEntry as TeamEntry$1, TeamPermission, TrackerDoctorReport, TrackerUser, VerifyTokenFailure, VerifyTokenResult, VerifyTokenResult as VerifyTokenResult$1, VerifyTokenSuccess, createFeatureResolver } from "@theholocron/cli";
1
2
  import { GitHubClient, GitHubClientOptions, createGitHubClient } from "@theholocron/github-client";
2
- import { Auth, AuthError, Ci, CiRun, CiRunFilter, Environment, Environments, Issue, IssueSearchFilter, Issues, LabelDef, LifecycleResult, LifecycleSlot, PagesConfig, PullRequest, RepoRef, RepoSettings, ResolveTokenInput, Ruleset, SecretScope, Secrets, Source, TeamEntry, TeamEntry as TeamEntry$1, TeamPermission, TrackerDoctorReport, TrackerUser, createFeatureResolver } from "@theholocron/cli";
3
- //#region src/auth.d.ts
4
- declare const resolveReadToken: (input?: ResolveTokenInput) => string;
5
- declare const resolveIssuesToken: (input?: ResolveTokenInput) => string;
6
- declare const resolveSyncToken: (input?: ResolveTokenInput) => string;
7
- declare const resolveReleaseToken: (input?: ResolveTokenInput) => string;
8
- declare const resolveAdminToken: (input?: ResolveTokenInput) => string;
9
- declare const resolveDeployToken: (input?: ResolveTokenInput) => string;
10
- declare const resolveOrgToken: (input?: ResolveTokenInput) => string;
11
- //#endregion
12
- //#region src/capabilities/ci.d.ts
3
+ export declare const resolveReadToken: (input?: ResolveTokenInput) => string;
4
+ export declare const resolveIssuesToken: (input?: ResolveTokenInput) => string;
5
+ export declare const resolveSyncToken: (input?: ResolveTokenInput) => string;
6
+ export declare const resolveReleaseToken: (input?: ResolveTokenInput) => string;
7
+ export declare const resolveAdminToken: (input?: ResolveTokenInput) => string;
8
+ export declare const resolveSecretsToken: (input?: ResolveTokenInput) => string;
9
+ export declare const resolveDeployToken: (input?: ResolveTokenInput) => string;
10
+ export declare const resolveUserSyncToken: (input?: ResolveTokenInput) => string;
11
+ export declare const resolveOrgToken: (input?: ResolveTokenInput) => string;
13
12
  interface CiOptions {
14
13
  repo: string;
15
14
  }
16
- declare class GitHubCi implements Ci {
15
+ export declare class GitHubCi implements Ci {
17
16
  private readonly client;
18
17
  private readonly opts;
19
18
  readonly key: "ci";
@@ -24,12 +23,10 @@ declare class GitHubCi implements Ci {
24
23
  private mapRun;
25
24
  private mapConclusion;
26
25
  }
27
- //#endregion
28
- //#region src/capabilities/environments.d.ts
29
26
  interface EnvironmentsOptions {
30
27
  repo: string;
31
28
  }
32
- declare class GitHubEnvironments implements Environments {
29
+ export declare class GitHubEnvironments implements Environments {
33
30
  private readonly client;
34
31
  private readonly opts;
35
32
  readonly key: "environments";
@@ -40,8 +37,6 @@ declare class GitHubEnvironments implements Environments {
40
37
  deleteEnvironment(name: string): Promise<void>;
41
38
  private flattenReviewers;
42
39
  }
43
- //#endregion
44
- //#region src/capabilities/issues.d.ts
45
40
  interface IssuesOptions {
46
41
  repo: string;
47
42
  /**
@@ -55,7 +50,7 @@ interface IssuesOptions {
55
50
  inReview: string;
56
51
  };
57
52
  }
58
- declare class GitHubIssues implements Issues {
53
+ export declare class GitHubIssues implements Issues {
59
54
  private readonly client;
60
55
  readonly key: "issues";
61
56
  readonly providerName = "github";
@@ -82,8 +77,6 @@ declare class GitHubIssues implements Issues {
82
77
  private removeStatusLabels;
83
78
  private mapIssue;
84
79
  }
85
- //#endregion
86
- //#region src/capabilities/labels.d.ts
87
80
  interface CanonicalLabel {
88
81
  readonly name: string;
89
82
  readonly color: string;
@@ -97,34 +90,39 @@ interface CanonicalLabel {
97
90
  * - DELETEs labels listed in `stale`.
98
91
  * - Leaves unrecognised labels that aren't in `stale` untouched.
99
92
  */
100
- declare function syncLabels(client: GitHubClient, repo: string, canonical: ReadonlyArray<CanonicalLabel>, stale: ReadonlyArray<string>): Promise<string>;
101
- //#endregion
102
- //#region src/capabilities/secrets.d.ts
93
+ export declare function syncLabels(client: GitHubClient, repo: string, canonical: ReadonlyArray<CanonicalLabel>, stale: ReadonlyArray<string>): Promise<string>;
103
94
  interface SecretsOptions {
104
95
  repo: string;
105
96
  }
106
- declare class GitHubSecrets implements Secrets {
97
+ export declare class GitHubSecrets implements Secrets {
107
98
  private readonly client;
108
99
  private readonly opts;
100
+ private readonly orgClient?;
109
101
  readonly key: "secrets";
110
102
  readonly providerName = "github";
111
- constructor(client: GitHubClient, opts: SecretsOptions);
103
+ /**
104
+ * `client` handles repo and environment scopes; `orgClient`, when given, handles
105
+ * the organization scope. They differ when a dedicated org-only token exists:
106
+ * it can write org secrets but not repo ones, and `github.admin` is the reverse.
107
+ */
108
+ constructor(client: GitHubClient, opts: SecretsOptions, orgClient?: GitHubClient | undefined);
109
+ private clientFor;
112
110
  listSecrets(scope: SecretScope): Promise<string[]>;
113
111
  setSecret(scope: SecretScope, name: string, value: string): Promise<void>;
114
112
  deleteSecret(scope: SecretScope, name: string): Promise<void>;
115
113
  }
116
- //#endregion
117
- //#region src/capabilities/source.d.ts
118
114
  interface SourceOptions {
119
115
  repo: string;
120
116
  repoRoot: string;
121
117
  /** Secondary client authenticated with HOLOCRON_ORG_TOKEN for org-scoped operations (teams, custom properties). */
122
118
  orgClient?: GitHubClient;
119
+ /** Client for the files that link GitHub accounts to other services (HOLOCRON_USER_SYNC_TOKEN); falls back to the org client. */
120
+ userSyncClient?: GitHubClient;
123
121
  /** Forwarded to a secondary client created for Pages calls (HOLOCRON_DEPLOY_TOKEN). */
124
122
  baseUrl?: string;
125
123
  fetch?: typeof fetch;
126
124
  }
127
- declare class GitHubSource implements Source {
125
+ export declare class GitHubSource implements Source {
128
126
  private readonly client;
129
127
  readonly key: "source";
130
128
  readonly providerName = "github";
@@ -134,6 +132,7 @@ declare class GitHubSource implements Source {
134
132
  private readonly repoRoot;
135
133
  private readonly workflowDir;
136
134
  private readonly orgClient?;
135
+ private readonly userSyncClient?;
137
136
  private readonly baseUrl?;
138
137
  private readonly fetchImpl?;
139
138
  constructor(client: GitHubClient, opts: SourceOptions);
@@ -161,56 +160,47 @@ declare class GitHubSource implements Source {
161
160
  syncLabels(canonical: ReadonlyArray<LabelDef>, stale: ReadonlyArray<string>): Promise<string>;
162
161
  syncProperties(values: Record<string, string | string[]>): Promise<string>;
163
162
  syncTeams(teams: TeamEntry$1[]): Promise<string>;
163
+ teamMembers(teamSlug: string, org?: string): Promise<SourceTeamMember[]>;
164
+ readFile(path: string, repo?: string): Promise<SourceFile | null>;
165
+ writeFile(path: string, content: string, opts: {
166
+ message: string;
167
+ version?: string;
168
+ repo?: string;
169
+ }): Promise<void>;
164
170
  syncTopics(topics: string[]): Promise<string>;
171
+ syncVariables(variables: Record<string, string>, opts?: {
172
+ prune?: boolean;
173
+ dryRun?: boolean;
174
+ }): Promise<string>;
175
+ syncOrgVariables(org: string, variables: Record<string, {
176
+ value: string;
177
+ visibility: "all" | "private";
178
+ }>, opts?: {
179
+ prune?: boolean;
180
+ dryRun?: boolean;
181
+ }): Promise<string>;
165
182
  syncDescription(description: string): Promise<string>;
166
183
  syncHomepage(homepage: string): Promise<string>;
167
184
  getPullRequest(number: number, repo?: string): Promise<PullRequest>;
168
185
  configurePages(config: PagesConfig, token: string): Promise<void>;
169
186
  }
170
- //#endregion
171
- //#region src/capabilities/teams.d.ts
172
187
  interface NormalizedTeamEntry {
173
188
  slug: string;
174
189
  permission: TeamPermission;
175
190
  }
176
- declare function normalizeTeamEntry(entry: TeamEntry): NormalizedTeamEntry;
177
- declare function syncTeams(client: GitHubClient, repo: string, teams: TeamEntry[]): Promise<string>;
178
- //#endregion
179
- //#region src/sodium.d.ts
191
+ export declare function normalizeTeamEntry(entry: TeamEntry): NormalizedTeamEntry;
192
+ export declare function syncTeams(client: GitHubClient, repo: string, teams: TeamEntry[]): Promise<string>;
180
193
  /**
181
194
  * Encrypt `value` for the given GitHub-provided base64 public key.
182
195
  * Returns the base64 ciphertext to send as `encrypted_value`.
183
196
  */
184
- declare function encryptSecret(publicKeyBase64: string, value: string): Promise<string>;
185
- //#endregion
186
- //#region src/verify-token.d.ts
187
- /**
188
- * `verifyToken` — plugin-level export used by `holocron auth set` +
189
- * `holocron auth check`. Hits `GET /user` and translates the response
190
- * into the normalized `VerifyTokenResult` shape.
191
- *
192
- * Kept as a standalone function (not a capability method) so the auth
193
- * command can call it without initializing the full plugin — plugin
194
- * construction requires an already-resolved token, which is exactly
195
- * what we don't have yet at bootstrap time.
196
- */
197
- interface VerifyTokenSuccess {
198
- ok: true;
199
- subject: string;
200
- }
201
- interface VerifyTokenFailure {
202
- ok: false;
203
- message: string;
204
- }
205
- type VerifyTokenResult = VerifyTokenSuccess | VerifyTokenFailure;
197
+ export declare function encryptSecret(publicKeyBase64: string, value: string): Promise<string>;
206
198
  interface VerifyTokenOptions {
207
199
  baseUrl?: string;
208
200
  fetch?: typeof fetch;
209
201
  }
210
- declare function verifyToken(token: string, opts?: VerifyTokenOptions): Promise<VerifyTokenResult>;
211
- //#endregion
212
- //#region src/index.d.ts
213
- interface GitHubPluginOptions extends ResolveTokenInput {
202
+ export declare function verifyToken(token: string, opts?: VerifyTokenOptions): Promise<VerifyTokenResult$1>;
203
+ export interface GitHubPluginOptions extends ResolveTokenInput {
214
204
  /** "owner/name" — e.g., "theholocron/holocron". Required. */
215
205
  repo: string;
216
206
  /** Absolute path to the working repo root. Used by `source`'s
@@ -226,19 +216,22 @@ interface GitHubPluginOptions extends ResolveTokenInput {
226
216
  /** Override `fetch` for tests. Defaults to global `fetch`. */
227
217
  fetch?: typeof fetch;
228
218
  }
229
- interface PluginContext {
219
+ export interface PluginContext {
230
220
  options: GitHubPluginOptions;
231
221
  client: GitHubClient;
232
222
  repo: string;
233
223
  repoRoot: string;
234
224
  }
235
- declare function source(ctx: PluginContext, orgClient?: GitHubClient): Source;
236
- declare function secrets(ctx: PluginContext): Secrets;
237
- declare function environments(ctx: PluginContext): Environments;
238
- declare function ci(ctx: PluginContext): Ci;
239
- declare function issues(ctx: PluginContext): Issues;
240
- declare function createPlugin(options: GitHubPluginOptions): {
225
+ export declare function source(ctx: PluginContext, orgClient?: GitHubClient, userSyncClient?: GitHubClient): Source;
226
+ export declare function secrets(ctx: PluginContext, orgClient?: GitHubClient): Secrets;
227
+ export declare function environments(ctx: PluginContext): Environments;
228
+ export declare function ci(ctx: PluginContext): Ci;
229
+ export declare function issues(ctx: PluginContext): Issues;
230
+ export declare function createPlugin(options: GitHubPluginOptions): {
241
231
  name: string;
232
+ probes: {
233
+ connection: import("@theholocron/cli").Probe;
234
+ };
242
235
  capabilities: {
243
236
  source: () => Source;
244
237
  ci: () => Ci;
@@ -251,6 +244,5 @@ declare function createPlugin(options: GitHubPluginOptions): {
251
244
  * One-line hint printed by `holocron auth set github` when no token
252
245
  * is supplied or the supplied token is rejected.
253
246
  */
254
- declare const AUTH_HINT: string;
255
- //#endregion
256
- export { AUTH_HINT, type Auth, AuthError, type CanonicalLabel, GitHubCi, type GitHubClientOptions, GitHubEnvironments, GitHubIssues, GitHubPluginOptions, GitHubSecrets, GitHubSource, type NormalizedTeamEntry, PluginContext, type ResolveTokenInput, type TeamEntry, type TeamPermission, type VerifyTokenFailure, type VerifyTokenResult, type VerifyTokenSuccess, ci, createFeatureResolver, createGitHubClient, createPlugin, encryptSecret, environments, issues, normalizeTeamEntry, resolveAdminToken, resolveDeployToken, resolveIssuesToken, resolveOrgToken, resolveReadToken, resolveReleaseToken, resolveSyncToken, secrets, source, syncLabels, syncTeams, verifyToken };
247
+ export declare const AUTH_HINT: string;
248
+ export { type Auth, AuthError, type CanonicalLabel, type GitHubClientOptions, type NormalizedTeamEntry, type ResolveTokenInput, type TeamEntry, type TeamPermission, type VerifyTokenFailure, type VerifyTokenResult, type VerifyTokenSuccess, createFeatureResolver, createGitHubClient };
package/dist/index.mjs CHANGED
@@ -1,9 +1,12 @@
1
1
  import { createRequire } from "node:module";
2
+ import { AuthError, ProviderApiError, connectionProbe, createFeatureResolver } from "@theholocron/cli";
2
3
  import { createGitHubClient, createGitHubClient as createGitHubClient$1 } from "@theholocron/github-client";
3
- import { AuthError, createFeatureResolver } from "@theholocron/cli";
4
4
  import { mkdir, readFile, readdir, rm, stat, writeFile } from "node:fs/promises";
5
5
  import { dirname, join } from "node:path";
6
- //#region src/auth.ts
6
+ import { findRootSync } from "@theholocron/fs-utils";
7
+ import { ProviderApiError as ProviderApiError$1 } from "@theholocron/http-client";
8
+ import { errorMessage } from "@theholocron/misc-utils";
9
+ // src/auth.ts
7
10
  const resolveReadToken = createFeatureResolver({
8
11
  envName: "HOLOCRON_READ_TOKEN",
9
12
  keyringKey: "github.read"
@@ -24,16 +27,23 @@ const resolveAdminToken = createFeatureResolver({
24
27
  envName: "HOLOCRON_ADMIN_TOKEN",
25
28
  keyringKey: "github.admin"
26
29
  });
30
+ const resolveSecretsToken = createFeatureResolver({
31
+ envName: "HOLOCRON_SECRETS_TOKEN",
32
+ keyringKey: "github.secrets"
33
+ });
27
34
  const resolveDeployToken = createFeatureResolver({
28
35
  envName: "HOLOCRON_DEPLOY_TOKEN",
29
36
  keyringKey: "github.deploy"
30
37
  });
38
+ const resolveUserSyncToken = createFeatureResolver({
39
+ envName: "HOLOCRON_USER_SYNC_TOKEN",
40
+ keyringKey: "github.user-sync"
41
+ });
31
42
  const resolveOrgToken = createFeatureResolver({
32
43
  envName: "HOLOCRON_ORG_TOKEN",
33
44
  keyringKey: "github.org"
34
45
  });
35
- //#endregion
36
- //#region src/capabilities/ci.ts
46
+ // src/capabilities/ci.ts
37
47
  var GitHubCi = class {
38
48
  client;
39
49
  opts;
@@ -77,8 +87,7 @@ var GitHubCi = class {
77
87
  }
78
88
  }
79
89
  };
80
- //#endregion
81
- //#region src/capabilities/environments.ts
90
+ // src/capabilities/environments.ts
82
91
  var GitHubEnvironments = class {
83
92
  client;
84
93
  opts;
@@ -119,8 +128,7 @@ var GitHubEnvironments = class {
119
128
  }));
120
129
  }
121
130
  };
122
- //#endregion
123
- //#region src/capabilities/issues.ts
131
+ // src/capabilities/issues.ts
124
132
  var GitHubIssues = class {
125
133
  client;
126
134
  key = "issues";
@@ -337,8 +345,7 @@ function parseIssueNumber(key, owner, repoName) {
337
345
  if (!/^\d+$/.test(num)) throw new Error(`Invalid GitHub issue key "${key}" — expected #N or N.`);
338
346
  return parseInt(num, 10);
339
347
  }
340
- //#endregion
341
- //#region src/sodium.ts
348
+ // src/sodium.ts
342
349
  /**
343
350
  * libsodium sealed-box encryption for GitHub Actions secrets.
344
351
  *
@@ -369,32 +376,42 @@ async function encryptSecret(publicKeyBase64, value) {
369
376
  const ciphertext = sodium.crypto_box_seal(plaintext, publicKey);
370
377
  return sodium.to_base64(ciphertext, sodium.base64_variants.ORIGINAL);
371
378
  }
372
- //#endregion
373
- //#region src/capabilities/secrets.ts
379
+ // src/capabilities/secrets.ts
374
380
  var GitHubSecrets = class {
375
381
  client;
376
382
  opts;
383
+ orgClient;
377
384
  key = "secrets";
378
385
  providerName = "github";
379
- constructor(client, opts) {
386
+ /**
387
+ * `client` handles repo and environment scopes; `orgClient`, when given, handles
388
+ * the organization scope. They differ when a dedicated org-only token exists:
389
+ * it can write org secrets but not repo ones, and `github.admin` is the reverse.
390
+ */
391
+ constructor(client, opts, orgClient) {
380
392
  this.client = client;
381
393
  this.opts = opts;
394
+ this.orgClient = orgClient;
395
+ }
396
+ clientFor(scope) {
397
+ return scope.kind === "organization" ? this.orgClient ?? this.client : this.client;
382
398
  }
383
399
  async listSecrets(scope) {
384
- return this.client.secrets.listSecrets(this.opts.repo, toGHScope(scope));
400
+ return this.clientFor(scope).secrets.listSecrets(this.opts.repo, toGHScope(scope));
385
401
  }
386
402
  async setSecret(scope, name, value) {
387
403
  const ghScope = toGHScope(scope);
388
- const pk = await this.client.secrets.getPublicKey(this.opts.repo, ghScope);
404
+ const client = this.clientFor(scope);
405
+ const pk = await client.secrets.getPublicKey(this.opts.repo, ghScope);
389
406
  const body = {
390
407
  encrypted_value: await encryptSecret(pk.key, value),
391
408
  key_id: pk.key_id
392
409
  };
393
410
  if (scope.kind === "organization") body.visibility = "all";
394
- await this.client.secrets.putSecret(this.opts.repo, ghScope, name, body);
411
+ await client.secrets.putSecret(this.opts.repo, ghScope, name, body);
395
412
  }
396
413
  async deleteSecret(scope, name) {
397
- await this.client.secrets.deleteSecret(this.opts.repo, toGHScope(scope), name);
414
+ await this.clientFor(scope).secrets.deleteSecret(this.opts.repo, toGHScope(scope), name);
398
415
  }
399
416
  };
400
417
  function toGHScope(scope) {
@@ -404,8 +421,46 @@ function toGHScope(scope) {
404
421
  };
405
422
  return scope;
406
423
  }
407
- //#endregion
408
- //#region src/capabilities/labels.ts
424
+ // src/capabilities/files.ts
425
+ /** Reads one file; `null` when it does not exist. `version` is the blob sha `writeFile` needs to replace exactly this one. */
426
+ /** A fine-grained token only reaches the repositories and permissions it was given; say which one is missing. */
427
+ function noAccess(repo, action, cause) {
428
+ return new Error(`the token cannot ${action} files in ${repo} (403): give the user-sync token (HOLOCRON_USER_SYNC_TOKEN, or holocron auth set github.user-sync) access to that repository with Contents: ${action === "read" ? "read" : "read and write"}`, { cause });
429
+ }
430
+ async function readRepoFile(client, repo, path) {
431
+ try {
432
+ const file = await client.git.getContents(repo, path);
433
+ if (file.encoding !== "base64") throw new Error(`${repo}/${path}: unexpected encoding \`${file.encoding}\``);
434
+ return {
435
+ content: Buffer.from(file.content, "base64").toString("utf-8"),
436
+ version: file.sha
437
+ };
438
+ } catch (err) {
439
+ if (err instanceof ProviderApiError && err.status === 403) throw noAccess(repo, "read", err);
440
+ if (!(err instanceof ProviderApiError && err.status === 404)) throw err;
441
+ }
442
+ try {
443
+ await client.repos.getRepo(repo);
444
+ } catch (err) {
445
+ if (err instanceof ProviderApiError && err.status === 404) throw new Error(`${repo} was not found: it does not exist, or the token cannot see it`, { cause: err });
446
+ throw err;
447
+ }
448
+ return null;
449
+ }
450
+ /** Creates or replaces one file in a commit; GitHub refuses (409) a `version` that has since changed. */
451
+ async function writeRepoFile(client, repo, path, content, opts) {
452
+ try {
453
+ await client.git.putContents(repo, path, {
454
+ message: opts.message,
455
+ content,
456
+ ...opts.version ? { sha: opts.version } : {}
457
+ });
458
+ } catch (err) {
459
+ if (err instanceof ProviderApiError && err.status === 403) throw noAccess(repo, "write", err);
460
+ throw err;
461
+ }
462
+ }
463
+ // src/capabilities/labels.ts
409
464
  /**
410
465
  * Idempotently synchronise a repo's labels to the canonical set.
411
466
  *
@@ -443,14 +498,74 @@ async function syncLabels(client, repo, canonical, stale) {
443
498
  }
444
499
  return `${created} created, ${updated} updated, ${deleted} deleted`;
445
500
  }
446
- //#endregion
447
- //#region src/capabilities/properties.ts
501
+ // src/capabilities/rejected-properties.ts
502
+ /** The values `repo setup` sends for `true_false` properties; anything else is rejected. */
503
+ const BOOLEANS = /* @__PURE__ */ new Set(["true", "false"]);
504
+ /**
505
+ * What is wrong with `values` when compared with the organization's property definitions, one sentence per
506
+ * problem, in the order the properties are given: a property the organization does not define, a value a
507
+ * `single_select` or `multi_select` property does not allow, or a `true_false` property that is not `true` or
508
+ * `false`. A `string` or `url` property has nothing to check here. Empty when every value fits its definition.
509
+ */
510
+ function rejectedProperties(values, schema) {
511
+ const byName = new Map(schema.map((property) => [property.property_name, property]));
512
+ const problems = [];
513
+ for (const [name, value] of Object.entries(values)) {
514
+ const definition = byName.get(name);
515
+ if (!definition) {
516
+ problems.push(`${name} is not a custom property of the organization`);
517
+ continue;
518
+ }
519
+ const sent = Array.isArray(value) ? value : [value];
520
+ if (definition.value_type === "true_false") {
521
+ for (const item of sent.filter((item) => !BOOLEANS.has(item))) problems.push(`${name} is "${item}", but it takes only true or false`);
522
+ continue;
523
+ }
524
+ const allowed = definition.allowed_values ?? [];
525
+ if (definition.value_type !== "single_select" && definition.value_type !== "multi_select") continue;
526
+ for (const item of sent.filter((item) => !allowed.includes(item))) problems.push(`${name} has "${item}", which is not one of its allowed values (${allowed.join(", ")})`);
527
+ }
528
+ return problems;
529
+ }
530
+ // src/capabilities/properties.ts
531
+ /** GitHub's own message from a 422 body (`{"message": ..., "errors": [...]}`), or the start of the body when it is not JSON. */
532
+ function githubMessage(details) {
533
+ const raw = String(details ?? "").trim();
534
+ try {
535
+ const body = JSON.parse(raw);
536
+ const errors = Array.isArray(body.errors) ? body.errors.map((e) => JSON.stringify(e)).join(", ") : "";
537
+ const message = typeof body.message === "string" ? body.message : raw;
538
+ return errors ? `${message}: ${errors}` : message;
539
+ } catch {
540
+ return raw.slice(0, 300);
541
+ }
542
+ }
543
+ /** The organization's property definitions, or `undefined` when they cannot be read (permission, an older client). */
544
+ async function orgSchema(client, repo) {
545
+ try {
546
+ return await client.properties.orgSchema(repo.split("/")[0]);
547
+ } catch {
548
+ return;
549
+ }
550
+ }
551
+ /**
552
+ * Sets the repo's custom properties. When GitHub answers 422 the error says which value it refused: the values
553
+ * are compared with the organization's property definitions (read-only), and GitHub's own message is added.
554
+ * One refused value fails the whole request, so none of the properties is set.
555
+ */
448
556
  async function syncProperties(client, repo, values) {
449
- await client.properties.setProperties(repo, values);
557
+ try {
558
+ await client.properties.setProperties(repo, values);
559
+ } catch (err) {
560
+ if (!(err instanceof ProviderApiError$1) || err.status !== 422) throw err;
561
+ const schema = await orgSchema(client, repo);
562
+ const problems = schema ? rejectedProperties(values, schema) : [];
563
+ const said = githubMessage(err.details);
564
+ throw new Error(`GitHub refused the custom properties (422), so none was set: ${problems.length > 0 ? problems.join("; ") : "no value differs from the organization's definitions, or they could not be read"}. GitHub said: ${said}`, { cause: err });
565
+ }
450
566
  return `${Object.keys(values).length} properties set`;
451
567
  }
452
- //#endregion
453
- //#region src/capabilities/teams.ts
568
+ // src/capabilities/teams.ts
454
569
  function normalizeTeamEntry(entry) {
455
570
  if (typeof entry === "string") return {
456
571
  slug: entry,
@@ -470,14 +585,112 @@ async function syncTeams(client, repo, teams) {
470
585
  }
471
586
  return `${succeeded} team${succeeded === 1 ? "" : "s"} synced`;
472
587
  }
473
- //#endregion
474
- //#region src/capabilities/topics.ts
588
+ // src/capabilities/topics.ts
475
589
  async function syncTopics(client, repo, topics) {
476
590
  await client.topics.setTopics(repo, topics);
477
591
  return `${topics.length} topics set`;
478
592
  }
479
- //#endregion
480
- //#region src/capabilities/source.ts
593
+ // src/capabilities/variables.ts
594
+ /** GitHub's rule for a variable name: letters, digits and underscores, not starting with a digit or `GITHUB_`. */
595
+ const NAME = /^[A-Za-z_][A-Za-z0-9_]*$/;
596
+ /** Longest name GitHub accepts. */
597
+ const MAX_NAME = 256;
598
+ function assertNames(names) {
599
+ for (const name of names) if (!NAME.test(name) || name.length > MAX_NAME || name.toUpperCase().startsWith("GITHUB_")) throw new Error(`invalid variable name ${JSON.stringify(name)}: use letters, digits and underscores (at most ${MAX_NAME}), not starting with a digit or GITHUB_`);
600
+ }
601
+ /** GitHub treats variable names as case-insensitive, so every comparison goes through this. */
602
+ const key = (name) => name.toUpperCase();
603
+ /**
604
+ * Bring one scope's variables in line with the declaration: create what is missing, update what differs, leave what
605
+ * already matches. Variables that exist but are not declared are left alone, or deleted with `prune`, except an
606
+ * organization variable limited to selected repositories, which this client cannot manage and so is never deleted.
607
+ * Returns a one-line summary, naming the ones left so drift is visible. In a dry run it only describes the changes.
608
+ */
609
+ async function reconcile(client, repo, scope, wanted, opts) {
610
+ assertNames(wanted.map((w) => w.name));
611
+ const existing = new Map((await client.variables.listVariables(repo, scope)).map((v) => [key(v.name), v]));
612
+ const counts = {
613
+ created: 0,
614
+ updated: 0,
615
+ unchanged: 0,
616
+ deleted: 0
617
+ };
618
+ for (const want of wanted) {
619
+ const have = existing.get(key(want.name));
620
+ const body = {
621
+ name: want.name,
622
+ value: want.value
623
+ };
624
+ if (want.visibility) body["visibility"] = want.visibility;
625
+ if (!have) {
626
+ if (opts.dryRun) {
627
+ counts.created++;
628
+ continue;
629
+ }
630
+ try {
631
+ await client.variables.createVariable(repo, scope, body);
632
+ counts.created++;
633
+ continue;
634
+ } catch (err) {
635
+ if (!(err instanceof ProviderApiError && (err.status === 409 || err.status === 422))) throw err;
636
+ }
637
+ } else if (have.value === want.value && (!want.visibility || have.visibility === want.visibility)) {
638
+ counts.unchanged++;
639
+ continue;
640
+ }
641
+ if (!opts.dryRun) await client.variables.updateVariable(repo, scope, have?.name ?? want.name, body);
642
+ counts.updated++;
643
+ }
644
+ const declared = new Set(wanted.map((w) => key(w.name)));
645
+ const undeclared = [...existing.entries()].filter(([k]) => !declared.has(k)).map(([, v]) => v);
646
+ const unmanaged = undeclared.filter((v) => v.visibility === "selected");
647
+ if (opts.prune) for (const v of undeclared.filter((u) => u.visibility !== "selected")) {
648
+ if (!opts.dryRun) await client.variables.deleteVariable(repo, scope, v.name);
649
+ counts.deleted++;
650
+ }
651
+ const parts = opts.dryRun ? [
652
+ `would create ${counts.created}`,
653
+ `would update ${counts.updated}`,
654
+ `${counts.unchanged} unchanged`
655
+ ] : [
656
+ `${counts.created} created`,
657
+ `${counts.updated} updated`,
658
+ `${counts.unchanged} unchanged`
659
+ ];
660
+ if (counts.deleted > 0) parts.push(`${counts.deleted} ${opts.dryRun ? "would be deleted" : "deleted"}`);
661
+ const left = opts.prune ? unmanaged : undeclared;
662
+ if (left.length > 0) {
663
+ const why = opts.prune ? "limited to selected repositories, not managed" : "not declared, left alone";
664
+ parts.push(`${left.length} ${why}: ${left.map((v) => v.name).join(", ")}`);
665
+ }
666
+ return parts.join(", ");
667
+ }
668
+ /** Reconcile a repository's Actions variables. */
669
+ function syncVariables(client, repo, variables, opts = {}) {
670
+ return reconcile(client, repo, { kind: "repo" }, Object.entries(variables).map(([name, value]) => ({
671
+ name,
672
+ value
673
+ })), {
674
+ prune: opts.prune === true,
675
+ dryRun: opts.dryRun === true
676
+ });
677
+ }
678
+ /** Reconcile an organization's Actions variables. `repo` is only the coordinate the client is bound to. */
679
+ function syncOrgVariables(client, repo, org, variables, opts = {}) {
680
+ const wanted = Object.entries(variables).map(([name, v]) => ({
681
+ name,
682
+ value: v.value,
683
+ visibility: v.visibility
684
+ }));
685
+ return reconcile(client, repo, {
686
+ kind: "organization",
687
+ org
688
+ }, wanted, {
689
+ prune: opts.prune === true,
690
+ dryRun: opts.dryRun === true
691
+ });
692
+ }
693
+ // src/capabilities/source.ts
481
694
  var GitHubSource = class {
482
695
  client;
483
696
  key = "source";
@@ -488,6 +701,7 @@ var GitHubSource = class {
488
701
  repoRoot;
489
702
  workflowDir;
490
703
  orgClient;
704
+ userSyncClient;
491
705
  baseUrl;
492
706
  fetchImpl;
493
707
  constructor(client, opts) {
@@ -496,9 +710,13 @@ var GitHubSource = class {
496
710
  this.owner = owner;
497
711
  this.name = name;
498
712
  this.repo = opts.repo;
499
- this.repoRoot = opts.repoRoot;
500
- this.workflowDir = join(opts.repoRoot, ".github", "workflows");
713
+ this.repoRoot = findRootSync(opts.repoRoot, ".git", {
714
+ fallback: "start",
715
+ realpath: true
716
+ });
717
+ this.workflowDir = join(this.repoRoot, ".github", "workflows");
501
718
  this.orgClient = opts.orgClient;
719
+ this.userSyncClient = opts.userSyncClient;
502
720
  this.baseUrl = opts.baseUrl;
503
721
  this.fetchImpl = opts.fetch;
504
722
  }
@@ -591,9 +809,28 @@ var GitHubSource = class {
591
809
  async syncTeams(teams) {
592
810
  return syncTeams(this.orgClient ?? this.client, this.repo, teams);
593
811
  }
812
+ async teamMembers(teamSlug, org = this.owner) {
813
+ return (await (this.orgClient ?? this.client).teams.members(org, teamSlug)).map((m) => ({
814
+ id: String(m.id),
815
+ login: m.login
816
+ }));
817
+ }
818
+ async readFile(path, repo = this.repo) {
819
+ return readRepoFile(this.userSyncClient ?? this.orgClient ?? this.client, repo, path);
820
+ }
821
+ async writeFile(path, content, opts) {
822
+ return writeRepoFile(this.userSyncClient ?? this.orgClient ?? this.client, opts.repo ?? this.repo, path, content, opts);
823
+ }
594
824
  async syncTopics(topics) {
595
825
  return syncTopics(this.client, this.repo, topics);
596
826
  }
827
+ async syncVariables(variables, opts) {
828
+ return syncVariables(this.client, this.repo, variables, opts);
829
+ }
830
+ async syncOrgVariables(org, variables, opts) {
831
+ if (!this.orgClient) throw new Error("organization variables need the org token: set HOLOCRON_ORG_TOKEN or run `holocron auth set github.org <token>`");
832
+ return syncOrgVariables(this.orgClient, this.repo, org, variables, opts);
833
+ }
597
834
  async syncDescription(description) {
598
835
  await this.client.repos.updateRepo(this.repo, { description });
599
836
  return "description updated";
@@ -650,18 +887,7 @@ async function ensureDir(path) {
650
887
  throw err;
651
888
  }
652
889
  }
653
- //#endregion
654
- //#region src/verify-token.ts
655
- /**
656
- * `verifyToken` — plugin-level export used by `holocron auth set` +
657
- * `holocron auth check`. Hits `GET /user` and translates the response
658
- * into the normalized `VerifyTokenResult` shape.
659
- *
660
- * Kept as a standalone function (not a capability method) so the auth
661
- * command can call it without initializing the full plugin — plugin
662
- * construction requires an already-resolved token, which is exactly
663
- * what we don't have yet at bootstrap time.
664
- */
890
+ // src/verify-token.ts
665
891
  async function verifyToken(token, opts = {}) {
666
892
  const client = createGitHubClient({
667
893
  token,
@@ -677,23 +903,23 @@ async function verifyToken(token, opts = {}) {
677
903
  } catch (err) {
678
904
  return {
679
905
  ok: false,
680
- message: err instanceof Error ? err.message : String(err)
906
+ message: errorMessage(err)
681
907
  };
682
908
  }
683
909
  }
684
- //#endregion
685
- //#region src/index.ts
686
- function source(ctx, orgClient) {
910
+ // src/index.ts
911
+ function source(ctx, orgClient, userSyncClient) {
687
912
  return new GitHubSource(ctx.client, {
688
913
  repo: ctx.repo,
689
914
  repoRoot: ctx.repoRoot,
690
915
  orgClient,
916
+ userSyncClient,
691
917
  baseUrl: ctx.options.baseUrl,
692
918
  fetch: ctx.options.fetch
693
919
  });
694
920
  }
695
- function secrets(ctx) {
696
- return new GitHubSecrets(ctx.client, { repo: ctx.repo });
921
+ function secrets(ctx, orgClient) {
922
+ return new GitHubSecrets(ctx.client, { repo: ctx.repo }, orgClient);
697
923
  }
698
924
  function environments(ctx) {
699
925
  return new GitHubEnvironments(ctx.client, { repo: ctx.repo });
@@ -708,10 +934,11 @@ function issues(ctx) {
708
934
  }
709
935
  function createPlugin(options) {
710
936
  function makeCtx(resolver) {
937
+ const token = resolver(options);
711
938
  return {
712
939
  options,
713
940
  client: createGitHubClient$1({
714
- token: resolver(options),
941
+ token,
715
942
  baseUrl: options.baseUrl,
716
943
  fetch: options.fetch
717
944
  }),
@@ -719,10 +946,45 @@ function createPlugin(options) {
719
946
  repoRoot: options.repoRoot ?? process.cwd()
720
947
  };
721
948
  }
949
+ /** A token, or undefined when that feature token is simply not configured. */
950
+ function tryResolve(resolver) {
951
+ try {
952
+ return resolver(options);
953
+ } catch (err) {
954
+ if (err instanceof AuthError) return void 0;
955
+ throw err;
956
+ }
957
+ }
958
+ /**
959
+ * Organization secrets need an org-resource-owner token (`github.secrets`);
960
+ * repo and environment secrets need `github.admin`. Each scope prefers its own
961
+ * token and falls back to the other, so a setup with only one of them keeps
962
+ * working, and an explicit `--token` serves both. With neither configured this
963
+ * fails exactly as the admin-token capabilities do.
964
+ */
965
+ function makeSecrets() {
966
+ const secretsToken = tryResolve(resolveSecretsToken);
967
+ const adminToken = tryResolve(resolveAdminToken);
968
+ if (!secretsToken && !adminToken) resolveAdminToken(options);
969
+ const clientFor = (token) => createGitHubClient$1({
970
+ token,
971
+ baseUrl: options.baseUrl,
972
+ fetch: options.fetch
973
+ });
974
+ const repoClient = clientFor(adminToken ?? secretsToken);
975
+ const orgClient = clientFor(secretsToken ?? adminToken);
976
+ return secrets({
977
+ options,
978
+ client: repoClient,
979
+ repo: options.repo,
980
+ repoRoot: options.repoRoot ?? process.cwd()
981
+ }, orgClient);
982
+ }
722
983
  function makeOrgClient() {
723
984
  try {
985
+ const token = resolveOrgToken(options);
724
986
  return createGitHubClient$1({
725
- token: resolveOrgToken(options),
987
+ token,
726
988
  baseUrl: options.baseUrl,
727
989
  fetch: options.fetch
728
990
  });
@@ -730,12 +992,31 @@ function createPlugin(options) {
730
992
  return;
731
993
  }
732
994
  }
995
+ /** The token for the user-sync files; undefined when it is not configured, and the org token is used instead. */
996
+ function makeUserSyncClient() {
997
+ const token = tryResolve(resolveUserSyncToken);
998
+ return token ? createGitHubClient$1({
999
+ token,
1000
+ baseUrl: options.baseUrl,
1001
+ fetch: options.fetch
1002
+ }) : void 0;
1003
+ }
733
1004
  return {
734
1005
  name: "@theholocron/holocron-plugin-github",
1006
+ probes: { connection: connectionProbe({
1007
+ provider: "GitHub",
1008
+ url: options.baseUrl ?? "https://api.github.com",
1009
+ fetch: options.fetch,
1010
+ token: () => resolveReadToken(options),
1011
+ verify: (token) => verifyToken(token, {
1012
+ baseUrl: options.baseUrl,
1013
+ fetch: options.fetch
1014
+ })
1015
+ }) },
735
1016
  capabilities: {
736
- source: () => source(makeCtx(resolveAdminToken), makeOrgClient()),
1017
+ source: () => source(makeCtx(resolveAdminToken), makeOrgClient(), makeUserSyncClient()),
737
1018
  ci: () => ci(makeCtx(resolveReadToken)),
738
- secrets: () => secrets(makeCtx(resolveAdminToken)),
1019
+ secrets: () => makeSecrets(),
739
1020
  environments: () => environments(makeCtx(resolveAdminToken)),
740
1021
  issues: () => issues(makeCtx(resolveIssuesToken))
741
1022
  }
@@ -746,5 +1027,4 @@ function createPlugin(options) {
746
1027
  * is supplied or the supplied token is rejected.
747
1028
  */
748
1029
  const AUTH_HINT = "generate fine-grained Personal Access Tokens at https://github.com/settings/tokens (one per feature — see docs/tokens.md for required scopes per operation), then run: holocron auth set github.<feature> <PAT>";
749
- //#endregion
750
- export { AUTH_HINT, AuthError, GitHubCi, GitHubEnvironments, GitHubIssues, GitHubSecrets, GitHubSource, ci, createFeatureResolver, createGitHubClient, createPlugin, encryptSecret, environments, issues, normalizeTeamEntry, resolveAdminToken, resolveDeployToken, resolveIssuesToken, resolveOrgToken, resolveReadToken, resolveReleaseToken, resolveSyncToken, secrets, source, syncLabels, syncTeams, verifyToken };
1030
+ export { AUTH_HINT, AuthError, GitHubCi, GitHubEnvironments, GitHubIssues, GitHubSecrets, GitHubSource, ci, createFeatureResolver, createGitHubClient, createPlugin, encryptSecret, environments, issues, normalizeTeamEntry, resolveAdminToken, resolveDeployToken, resolveIssuesToken, resolveOrgToken, resolveReadToken, resolveReleaseToken, resolveSecretsToken, resolveSyncToken, resolveUserSyncToken, secrets, source, syncLabels, syncTeams, verifyToken };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@theholocron/holocron-plugin-github",
3
- "version": "5.0.0-alpha.26",
3
+ "version": "5.0.0-alpha.260",
4
4
  "description": "Holocron plugin for GitHub. Implements source, ci, secrets, environments, and issues capabilities against the GitHub REST API.",
5
5
  "keywords": [
6
6
  "ci",
@@ -9,12 +9,12 @@
9
9
  "plugin",
10
10
  "source"
11
11
  ],
12
- "homepage": "https://github.com/theholocron/holocron/tree/main/packages/holocron-plugin-github#readme",
12
+ "homepage": "https://github.com/theholocron/holocron/tree/main/plugins/holocron-plugin-github#readme",
13
13
  "bugs": "https://github.com/theholocron/holocron/issues",
14
14
  "repository": {
15
15
  "type": "git",
16
16
  "url": "git+https://github.com/theholocron/holocron.git",
17
- "directory": "packages/holocron-plugin-github"
17
+ "directory": "plugins/holocron-plugin-github"
18
18
  },
19
19
  "license": "MIT",
20
20
  "author": "Newton Koumantzelis",
@@ -34,27 +34,31 @@
34
34
  "libsodium-wrappers": "^0.8.4"
35
35
  },
36
36
  "devDependencies": {
37
- "@theholocron/eslint-config": "^8.0.0",
38
- "@theholocron/github-client": "^1.22.0",
39
- "@theholocron/http-client": "^1.22.0",
40
- "@theholocron/tsconfig": "^8.0.0",
41
- "@theholocron/tsdown-config": "^8.0.0",
42
- "@theholocron/vitest-config": "^8.0.0",
43
37
  "@types/node": "^26",
44
- "@vitest/coverage-v8": "^4.1.11",
38
+ "@vitest/coverage-v8": "^5.0.5",
45
39
  "@vitest/eslint-plugin": "^1.6.27",
46
40
  "eslint": "^10.8.1",
47
41
  "eslint-plugin-n": "^18.3.0",
48
42
  "globals": "^17.11.0",
49
- "tsdown": "^0.22.14",
50
- "tsx": "4.23.12",
43
+ "tsdown": "^0.23.3",
44
+ "tsx": "4.23.15",
51
45
  "typescript": "^5.9.3",
52
- "vitest": "^4.1.11",
53
- "@theholocron/cli": "5.0.0-alpha.26"
46
+ "vitest": "^5.0.5",
47
+ "@theholocron/cli": "5.0.0-alpha.260",
48
+ "@theholocron/fs-utils": "^1.16.1-alpha.1",
49
+ "@theholocron/eslint-config": "^8.9.0-alpha.3",
50
+ "@theholocron/github-client": "^1.57.1-alpha.2",
51
+ "@theholocron/misc-utils": "^1.16.1-alpha.1",
52
+ "@theholocron/tsdown-config": "^8.9.0-alpha.3",
53
+ "@theholocron/vitest-config": "^8.9.0-alpha.3",
54
+ "@theholocron/tsconfig": "^8.9.0-alpha.3",
55
+ "@theholocron/http-client": "^1.57.1-alpha.2"
54
56
  },
55
57
  "peerDependencies": {
56
- "@theholocron/github-client": "^1.22.0",
57
- "@theholocron/cli": "5.0.0-alpha.26"
58
+ "@theholocron/fs-utils": "^1.16.0",
59
+ "@theholocron/github-client": "^1.51.0",
60
+ "@theholocron/misc-utils": "^1.16.0",
61
+ "@theholocron/cli": "5.0.0-alpha.260"
58
62
  },
59
63
  "engines": {
60
64
  "node": ">=22"
@@ -64,10 +68,10 @@
64
68
  },
65
69
  "scripts": {
66
70
  "delivery.build": "tsdown",
67
- "sourceQuality.staticAnalysis": "holocron run sourceQuality.staticAnalysis --",
71
+ "sourceQuality.staticAnalysis": "eslint .",
68
72
  "test:watch": "vitest",
69
73
  "validate": "tsx scripts/validate.mjs",
70
- "verification.typeSafety": "holocron run verification.typeSafety --",
71
- "verification.unitTests": "holocron run verification.unitTests --"
74
+ "verification.typeSafety": "tsc --noEmit",
75
+ "verification.unitTests": "vitest run"
72
76
  }
73
77
  }