Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 42 additions & 2 deletions packages/code-storage-typescript/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,45 @@ const readOnlyUrl = await repo.getRemoteURL({
// - 'repo:write' - Create a repository
```

### Git HTTPS Credential Helper

Installing `@pierre/storage` also installs `git-credential-code-storage`, a Git
credential helper that lets you keep a plain HTTPS remote URL in Git config and
mints a fresh JWT each time Git asks for credentials:

```bash
export PIERRE_PRIVATE_KEY_FILE=/path/to/key.pem
git remote set-url origin https://your-name.code.storage/repo-id.git
git config credential.https://your-name.code.storage.helper code-storage
git config credential.https://your-name.code.storage.useHttpPath true
```

With this setup, `.git/config` stores only the token-free URL:

```text
url = https://your-name.code.storage/repo-id.git
```

At request time, the helper returns username `t` and a newly minted JWT as the
password. `useHttpPath=true` is required so Git passes the repository path to
the helper. The helper reads:

- `PIERRE_PRIVATE_KEY_FILE` or `PIERRE_PRIVATE_KEY` for the signing key.
- `PIERRE_TOKEN_TTL` for the minted JWT TTL in seconds, defaulting to 1 hour
(each Git operation requests fresh credentials).
- `PIERRE_DEBUG` to log token acquisition diagnostics to stderr (set to any
value except `0` or `false`). Minted tokens are never logged, and stdout
stays reserved for the Git credential protocol.

Nested repo IDs, ephemeral remotes, and import remotes use the same paths as
authenticated HTTPS URLs:

```bash
git remote set-url origin https://your-name.code.storage/team/project.git
git remote add ephemeral https://your-name.code.storage/repo-id+ephemeral.git
git remote add import https://your-name.code.storage/repo-id+import.git
```

#### Ephemeral Branches

For working with ephemeral branches (temporary branches isolated from the main
Expand Down Expand Up @@ -1002,14 +1041,15 @@ interface RestoreCommitResult {
## Authentication

The SDK uses JWT (JSON Web Tokens) for authentication. When you call
`getRemoteURL()`, it:
`getRemoteURL()` or configure the HTTPS credential helper, it:

1. Creates a JWT with your name, repository ID, and requested permissions
2. Signs it with your key
3. Embeds it in the Git remote URL as the password

The generated URLs are compatible with standard Git clients and include all
necessary authentication.
necessary authentication. The HTTPS credential helper keeps the token out of
Git config and mints a fresh token for each helper invocation.

## Error Handling

Expand Down
3 changes: 3 additions & 0 deletions packages/code-storage-typescript/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,9 @@
"default": "./dist/index.js"
}
},
"bin": {
"git-credential-code-storage": "./dist/git-credential-code-storage.js"
},
"files": [
"dist",
"src"
Expand Down
242 changes: 242 additions & 0 deletions packages/code-storage-typescript/src/git-credential-code-storage.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,242 @@
#!/usr/bin/env node
import { readFileSync, realpathSync } from 'node:fs';
import { fileURLToPath } from 'node:url';

import { GitStorage } from './index';

/**
* Default TTL for credentials minted by the helper. Git requests a fresh
* credential for every operation, so the token only needs to outlive a single
* fetch or push. Override with PIERRE_TOKEN_TTL (seconds).
*/
const DEFAULT_CREDENTIAL_TTL_SECONDS = 60 * 60;

interface GitCredentialInput {
protocol?: string;
host?: string;
path?: string;
}

type CredentialLog = (message: string) => void;

const noLog: CredentialLog = () => {};

/**
* Builds a logger that writes diagnostic lines to stderr when PIERRE_DEBUG is
* set (to anything except '0' or 'false'). stdout carries the credential
* protocol Git reads, so logs must never be written there. Minted tokens are
* never logged.
*/
function createCredentialLogger(
env: NodeJS.ProcessEnv,
stderr: NodeJS.WriteStream
): CredentialLog {
const flag = env.PIERRE_DEBUG?.trim().toLowerCase();
if (!flag || flag === '0' || flag === 'false') {
return noLog;
}
return (message) => {
stderr.write(`git-credential-code-storage: ${message}\n`);
};
}

/**
* Resolves a Git credential for a Code Storage HTTPS remote.
*
* Returns null when the request is not for a `*.code.storage` host so Git
* falls through to the next configured helper. Throws when the request is for
* Code Storage but cannot be served (missing path or signing key), since
* silently declining would make Git prompt for a password that does not exist.
*/
export async function getCodeStorageCredential(
input: GitCredentialInput,
env: NodeJS.ProcessEnv = process.env,
readFile: (path: string) => string = (path) => readFileSync(path, 'utf8'),
log: CredentialLog = noLog
): Promise<{ username: string; password: string } | null> {
if (input.protocol !== 'https' || !input.host) {
log('declining request: not an HTTPS remote with a host');
return null;
}

const name = parseOrganization(input.host);
if (!name) {
log(`declining request for ${input.host}: not a *.code.storage host`);
return null;
}
if (!input.path) {
throw new Error(
'Code Storage credential helper requires credential.useHttpPath=true'
);
}

const repoId = parseRepoId(input.path);
if (!repoId) {
throw new Error(`Could not determine a repository id from path: ${input.path}`);
}
log(
env.PIERRE_PRIVATE_KEY
? 'signing with private key from PIERRE_PRIVATE_KEY'
: `signing with private key from ${env.PIERRE_PRIVATE_KEY_FILE ?? '(unset)'}`
);

const key = env.PIERRE_PRIVATE_KEY ?? readPrivateKeyFile(env, readFile);
if (!key || key.trim() === '') {
throw new Error(
'Set PIERRE_PRIVATE_KEY_FILE or PIERRE_PRIVATE_KEY before using the Code Storage credential helper'
);
}

const ttl = parsePositiveInteger(env.PIERRE_TOKEN_TTL, DEFAULT_CREDENTIAL_TTL_SECONDS);
log(`acquiring git:write+git:read token for repo ${repoId} (org ${name}, ttl ${ttl}s)`);
const storage = new GitStorage({ name, key });
const remoteURL = await storage.repo({ id: repoId }).getRemoteURL({
permissions: ['git:write', 'git:read'],
ttl,
});
const url = new URL(remoteURL);
log(`token acquired for ${url.host}${url.pathname}`);
return {
username: decodeURIComponent(url.username),
password: decodeURIComponent(url.password),
};
}

export async function runGitCredentialCodeStorage(
argv: string[],
stdin: NodeJS.ReadStream = process.stdin,
stdout: NodeJS.WriteStream = process.stdout,
env: NodeJS.ProcessEnv = process.env,
stderr: NodeJS.WriteStream = process.stderr
): Promise<number> {
const log = createCredentialLogger(env, stderr);
// Git writes the credential description for every operation, so drain stdin
// before deciding anything to avoid EPIPE on the Git side.
const description = await readStdin(stdin);

// 'store' and 'erase' are no-ops: credentials are minted on demand and
// never persisted.
if (argv[2] !== 'get') {
log(`ignoring '${argv[2] ?? ''}' operation: only 'get' mints credentials`);
return 0;
}

const credential = await getCodeStorageCredential(
parseCredentialInput(description),
env,
undefined,
log
);
if (!credential) {
return 0;
}

stdout.write(`username=${credential.username}\npassword=${credential.password}\n\n`);
return 0;
}

/** Parses the `key=value` credential description Git writes to stdin. */
export function parseCredentialInput(input: string): GitCredentialInput {
const credential: GitCredentialInput = {};
for (const line of input.split(/\r?\n/)) {
if (line === '') {
break;
}
const separator = line.indexOf('=');
if (separator <= 0) {
continue;
}
const key = line.slice(0, separator);
const value = line.slice(separator + 1);
if (key === 'protocol' || key === 'host' || key === 'path') {
credential[key] = value;
}
}
return credential;
}

/**
* Extracts the organization from a `<org>.code.storage` host, tolerating an
* explicit port. Returns null for any other host.
*/
function parseOrganization(host: string): string | null {
const match = /^(.+)\.code\.storage(?::\d+)?$/i.exec(host);
return match ? match[1] : null;
}

/**
* Derives the repository id from the URL path of an authenticated Code
* Storage HTTPS remote. Ephemeral (`+ephemeral`) and import (`+import`)
* remotes use the base repository's credentials, so their suffixes are
* stripped.
*/
function parseRepoId(path: string): string {
let repoPath = path.replace(/^\/+/, '').replace(/\/+$/, '');
if (repoPath.endsWith('.git')) {
repoPath = repoPath.slice(0, -'.git'.length);
}
repoPath = repoPath.replace(/\+(ephemeral|import)$/, '');
return repoPath
.split('/')
.map((segment) => decodeURIComponent(segment))
.join('/');
}

function readPrivateKeyFile(
env: NodeJS.ProcessEnv,
readFile: (path: string) => string
): string | undefined {
const keyFile = env.PIERRE_PRIVATE_KEY_FILE;
return keyFile && keyFile.trim() !== '' ? readFile(keyFile) : undefined;
}

function parsePositiveInteger(value: string | undefined, fallback: number): number {
if (!value) {
return fallback;
}
const parsed = Number.parseInt(value, 10);
return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
}

function readStdin(stdin: NodeJS.ReadStream): Promise<string> {
const { promise, resolve, reject } = Promise.withResolvers<string>();
let input = '';
stdin.setEncoding('utf8');
stdin.on('data', (chunk) => {
input += chunk;
});
stdin.on('end', () => {
resolve(input);
});
stdin.on('error', reject);
return promise;
}

/**
* npm installs bins as symlinks, and Node keeps the symlink path in
* process.argv[1] while import.meta.url points at the real file, so both
* sides must be resolved before comparing.
*/
function isMainModule(): boolean {
const entry = process.argv[1];
if (!entry) {
return false;
}
try {
return realpathSync(entry) === fileURLToPath(import.meta.url);
} catch {
return false;
}
}

if (isMainModule()) {
runGitCredentialCodeStorage(process.argv)
.then((code) => {
process.exitCode = code;
})
.catch((error: unknown) => {
const message = error instanceof Error ? error.message : String(error);
console.error(`git-credential-code-storage: ${message}`);
process.exitCode = 1;
});
}
Loading
Loading