hereya-cli 0.86.2 → 0.87.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.
@@ -0,0 +1,116 @@
1
+ import { Command, Flags } from '@oclif/core';
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
4
+ import { withEphemeralToken } from '../../lib/ephemeral-token.js';
5
+ import { gitUtils } from '../../lib/git-utils.js';
6
+ import { getDefaultLogger } from '../../lib/log.js';
7
+ import { shellUtils } from '../../lib/shell.js';
8
+ export default class Git extends Command {
9
+ static description = 'Run an arbitrary git command with the hereya credential helper available. Wraps `git <args...>`, propagating an ephemeral `--token` to the credential-helper grandchild via `HEREYA_EPHEMERAL_TOKEN`. After a successful `clone`, also wires the cloned repo to use the hereya credential helper for future `push`/`pull`/`fetch` operations. Use `--` to separate hereya flags from git flags so e.g. `--depth` reaches git instead of being parsed by oclif.';
10
+ static examples = [
11
+ '<%= config.bin %> <%= command.id %> --token <token> -- push',
12
+ '<%= config.bin %> <%= command.id %> --token <token> -- pull --rebase',
13
+ '<%= config.bin %> <%= command.id %> --token <token> -- clone https://github.com/owner/repo my-dir',
14
+ '<%= config.bin %> <%= command.id %> -- log --oneline -10',
15
+ ];
16
+ static flags = {
17
+ chdir: Flags.string({
18
+ description: 'directory to run git in (cwd of the spawned git process)',
19
+ required: false,
20
+ }),
21
+ token: Flags.string({
22
+ description: 'Ephemeral cloud access token used for this invocation only. Held in memory; never written to the keychain or `~/.hereya/secrets/`. Propagated to the git credential-helper grandchild via the HEREYA_EPHEMERAL_TOKEN env var. Takes precedence over the HEREYA_TOKEN environment variable.',
23
+ required: false,
24
+ }),
25
+ };
26
+ static strict = false;
27
+ async run() {
28
+ const { argv, flags } = await this.parse(Git);
29
+ // Precedence: --token flag > HEREYA_TOKEN env var > keychain.
30
+ if (flags.token) {
31
+ if (process.env.HEREYA_TOKEN) {
32
+ getDefaultLogger().debug('[git] --token flag overrides HEREYA_TOKEN environment variable.');
33
+ }
34
+ return withEphemeralToken(flags.token, () => this.runInner(argv, flags));
35
+ }
36
+ return this.runInner(argv, flags);
37
+ }
38
+ async runInner(gitArgs, flags) {
39
+ if (gitArgs.length === 0) {
40
+ this.error('Missing git arguments. Example: `hereya git push --token <…>`.');
41
+ }
42
+ const cwd = path.resolve(flags.chdir ?? process.env.HEREYA_PROJECT_ROOT_DIR ?? process.cwd());
43
+ const result = await shellUtils.runShell('git', gitArgs, {
44
+ directory: cwd,
45
+ stdio: 'inherit',
46
+ });
47
+ if (result.status !== 0) {
48
+ this.error(`git ${gitArgs.join(' ')} failed (exit ${result.status})`);
49
+ }
50
+ // Auto-wire the credential helper after a successful clone so future
51
+ // git operations from inside the cloned repo find hereya's helper.
52
+ if (gitArgs[0] === 'clone') {
53
+ const target = deriveCloneTarget(gitArgs.slice(1), cwd);
54
+ if (target && fs.existsSync(path.join(target, '.git'))) {
55
+ try {
56
+ await gitUtils.setupCredentialHelper({
57
+ hereyaBinPath: process.argv[1],
58
+ projectDir: target,
59
+ });
60
+ }
61
+ catch (error) {
62
+ // Don't fail the clone if the wire-up step fails — surface as warning.
63
+ this.warn(`Cloned ${gitArgs.join(' ')} successfully, but failed to wire the hereya credential helper in ${target}: ${error.message}`);
64
+ }
65
+ }
66
+ }
67
+ }
68
+ }
69
+ /**
70
+ * Best-effort resolution of the directory `git clone` wrote to. Mirrors git's
71
+ * own behavior: if the user passed an explicit target, that's it; otherwise
72
+ * derive it from the URL's basename (stripping any trailing `.git`).
73
+ *
74
+ * Returns an absolute path or `null` if we can't tell.
75
+ */
76
+ function deriveCloneTarget(cloneArgs, cwd) {
77
+ // Walk the args, skipping flag tokens (--xxx, -x) and their values.
78
+ // This is heuristic — git's flag schema is large — but it covers the
79
+ // common cases (`-c`, `--depth`, `--branch`, `--single-branch`, ...).
80
+ // If the heuristic is wrong, the existsSync(.git) check below will skip
81
+ // wire-up rather than touching the wrong directory.
82
+ const positional = [];
83
+ for (let i = 0; i < cloneArgs.length; i++) {
84
+ const a = cloneArgs[i];
85
+ if (a.startsWith('-')) {
86
+ // --flag=value: no separate value arg.
87
+ if (a.includes('='))
88
+ continue;
89
+ // -c key=value, --depth 1, etc.: skip the next arg as a value if it
90
+ // doesn't look like another flag.
91
+ if (i + 1 < cloneArgs.length && !cloneArgs[i + 1].startsWith('-')) {
92
+ i++;
93
+ }
94
+ continue;
95
+ }
96
+ positional.push(a);
97
+ }
98
+ // First positional is the URL; second (optional) is the target.
99
+ if (positional.length >= 2) {
100
+ return path.resolve(cwd, positional[1]);
101
+ }
102
+ if (positional.length === 1) {
103
+ const url = positional[0];
104
+ // Equivalent to Array.findLast(Boolean) but without requiring ES2023 lib.
105
+ const parts = url.replace(/\.git$/, '').split(/[/\\:]/);
106
+ let basename = '';
107
+ for (let i = parts.length - 1; i >= 0; i--) {
108
+ if (parts[i]) {
109
+ basename = parts[i];
110
+ break;
111
+ }
112
+ }
113
+ return basename ? path.resolve(cwd, basename) : null;
114
+ }
115
+ return null;
116
+ }
@@ -16,17 +16,24 @@ export interface EphemeralTokenContext {
16
16
  /**
17
17
  * Returns the active ephemeral token context, or `undefined` if no
18
18
  * `withEphemeralToken` scope is currently in flight.
19
+ *
20
+ * Falls back to the `HEREYA_EPHEMERAL_TOKEN` env var when no async-local-
21
+ * storage context is set — that's how subprocesses (the credential helper
22
+ * grandchild, etc.) inherit the ephemeral context across process
23
+ * boundaries. The env var is set automatically by `withEphemeralToken`.
19
24
  */
20
25
  export declare function getEphemeralToken(): EphemeralTokenContext | undefined;
21
26
  /**
22
27
  * Run `fn` inside an ephemeral-token scope. While `fn` is executing,
23
- * `getEphemeralToken()` returns the supplied token (and optional cloudUrl).
28
+ * `getEphemeralToken()` returns the supplied token (and optional cloudUrl),
29
+ * and `process.env.HEREYA_EPHEMERAL_TOKEN` is set so subprocesses inherit
30
+ * the same ephemeral context.
24
31
  *
25
32
  * Critical guarantees:
26
33
  * - The token is held in-memory only. It is NEVER passed to the secret
27
34
  * manager or written to disk.
28
35
  * - After `fn` returns or throws, `getEphemeralToken()` returns `undefined`
29
- * again.
36
+ * again and `HEREYA_EPHEMERAL_TOKEN` is restored to its prior value.
30
37
  * - The cached backend in `src/backend/index.ts` is cleared before and after
31
38
  * the scope so that a stale, persisted-credential-backed CloudBackend is
32
39
  * never used while the scope is active, and the scope's in-memory backend
@@ -1,22 +1,52 @@
1
1
  import { AsyncLocalStorage } from 'node:async_hooks';
2
2
  import { clearBackend } from '../backend/index.js';
3
3
  const storage = new AsyncLocalStorage();
4
+ /**
5
+ * Env-var names used to propagate the ephemeral token to **subprocesses**
6
+ * (e.g. the `git` invocation that triggers the `hereya credential-helper`
7
+ * grandchild). Async-local-storage doesn't cross process boundaries, so we
8
+ * also stash the token in the environment for the duration of the scope.
9
+ *
10
+ * These are deliberately named differently from `HEREYA_TOKEN` because the
11
+ * existing `HEREYA_TOKEN` semantics call `loginWithToken()` (exchange +
12
+ * keychain persist), which is exactly what ephemeral mode must NOT do.
13
+ */
14
+ const EPHEMERAL_TOKEN_ENV = 'HEREYA_EPHEMERAL_TOKEN';
15
+ const EPHEMERAL_CLOUD_URL_ENV = 'HEREYA_EPHEMERAL_CLOUD_URL';
4
16
  /**
5
17
  * Returns the active ephemeral token context, or `undefined` if no
6
18
  * `withEphemeralToken` scope is currently in flight.
19
+ *
20
+ * Falls back to the `HEREYA_EPHEMERAL_TOKEN` env var when no async-local-
21
+ * storage context is set — that's how subprocesses (the credential helper
22
+ * grandchild, etc.) inherit the ephemeral context across process
23
+ * boundaries. The env var is set automatically by `withEphemeralToken`.
7
24
  */
8
25
  export function getEphemeralToken() {
9
- return storage.getStore();
26
+ const fromAsyncStorage = storage.getStore();
27
+ if (fromAsyncStorage) {
28
+ return fromAsyncStorage;
29
+ }
30
+ const fromEnv = process.env[EPHEMERAL_TOKEN_ENV];
31
+ if (fromEnv) {
32
+ return {
33
+ cloudUrl: process.env[EPHEMERAL_CLOUD_URL_ENV] || undefined,
34
+ token: fromEnv,
35
+ };
36
+ }
37
+ return undefined;
10
38
  }
11
39
  /**
12
40
  * Run `fn` inside an ephemeral-token scope. While `fn` is executing,
13
- * `getEphemeralToken()` returns the supplied token (and optional cloudUrl).
41
+ * `getEphemeralToken()` returns the supplied token (and optional cloudUrl),
42
+ * and `process.env.HEREYA_EPHEMERAL_TOKEN` is set so subprocesses inherit
43
+ * the same ephemeral context.
14
44
  *
15
45
  * Critical guarantees:
16
46
  * - The token is held in-memory only. It is NEVER passed to the secret
17
47
  * manager or written to disk.
18
48
  * - After `fn` returns or throws, `getEphemeralToken()` returns `undefined`
19
- * again.
49
+ * again and `HEREYA_EPHEMERAL_TOKEN` is restored to its prior value.
20
50
  * - The cached backend in `src/backend/index.ts` is cleared before and after
21
51
  * the scope so that a stale, persisted-credential-backed CloudBackend is
22
52
  * never used while the scope is active, and the scope's in-memory backend
@@ -26,10 +56,33 @@ export function getEphemeralToken() {
26
56
  export async function withEphemeralToken(token, fn, options) {
27
57
  // Drop any cached backend so the next getBackend() call sees the ephemeral context.
28
58
  clearBackend();
59
+ // Snapshot existing env-var values so we can restore them on exit.
60
+ const priorTokenEnv = process.env[EPHEMERAL_TOKEN_ENV];
61
+ const priorCloudUrlEnv = process.env[EPHEMERAL_CLOUD_URL_ENV];
62
+ process.env[EPHEMERAL_TOKEN_ENV] = token;
63
+ if (options?.cloudUrl) {
64
+ process.env[EPHEMERAL_CLOUD_URL_ENV] = options.cloudUrl;
65
+ }
66
+ else {
67
+ delete process.env[EPHEMERAL_CLOUD_URL_ENV];
68
+ }
29
69
  try {
30
70
  return await storage.run({ cloudUrl: options?.cloudUrl, token }, fn);
31
71
  }
32
72
  finally {
73
+ // Restore prior env-var state (no leakage between sibling invocations).
74
+ if (priorTokenEnv === undefined) {
75
+ delete process.env[EPHEMERAL_TOKEN_ENV];
76
+ }
77
+ else {
78
+ process.env[EPHEMERAL_TOKEN_ENV] = priorTokenEnv;
79
+ }
80
+ if (priorCloudUrlEnv === undefined) {
81
+ delete process.env[EPHEMERAL_CLOUD_URL_ENV];
82
+ }
83
+ else {
84
+ process.env[EPHEMERAL_CLOUD_URL_ENV] = priorCloudUrlEnv;
85
+ }
33
86
  // Drop the in-memory backend bound to the ephemeral token before yielding control.
34
87
  clearBackend();
35
88
  }