Skip to content
Draft
Changes from 1 commit
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
104 changes: 104 additions & 0 deletions bin/cliOperations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import * as terms from '../utility/hdbTerms.ts';
import { httpRequest } from '../utility/common_utils.ts';
import * as path from 'path';
import * as fs from 'fs-extra';
import { execFileSync } from 'node:child_process';
import * as YAML from 'yaml';
import { streamPackagedDirectory, packageDirectory, scanPackageDirectory } from '../components/packageComponent.ts';
import { encode as encodeCbor } from 'cbor-x';
Expand Down Expand Up @@ -36,6 +37,11 @@ const TRANSPORT_ONLY_FIELDS = new Set([
'json',
'skip_node_modules',
'skip_symlinks',
// deploy-by-reference opt-in: consumed client-side to build `package` (and derive `credentials`),
// never sent to the server. (`credentials`, plural, IS a real operation field and is sent.)
'by_ref',
'ref',
'credential',
]);

// Streaming (multipart upload + SSE progress) deploy was introduced in 5.1.0. A CLI at >=
Expand Down Expand Up @@ -97,12 +103,110 @@ function operationFields(req: any): any {
}

export { cliOperations, buildRequest };

// --- deploy-by-reference (opt-in via `by_ref=true` / `ref=<committish>`) ----------------------
// Resolve the app's GitHub repo + commit from the local working copy (or GitHub Actions env) so
// `harper deploy by_ref=true` deploys a pinned commit by reference instead of uploading a payload
// blob. This is deliberately client-side: only the runner has the git context. The no-flag default
// stays the payload deploy, so this changes nothing for existing callers.

function runGit(args: string[]): string {
return execFileSync('git', args, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim();
}

function resolveGitRepo(): string {
if (process.env.GITHUB_REPOSITORY) return process.env.GITHUB_REPOSITORY;
let url: string;
try {
url = runGit(['remote', 'get-url', 'origin']);
} catch {
throw new Error(
'deploy by_ref: no git `origin` remote found — push this project to GitHub, or pass an explicit package=.'
);
}
// git@github.com:owner/repo.git | https://github.com/owner/repo(.git) | ssh://…
const match = url.match(/github\.com[:/]+([^/]+\/[^/]+?)(?:\.git)?\/?$/i);
if (!match) throw new Error(`deploy by_ref: could not parse owner/repo from the origin remote: ${url}`);
return match[1];
}

function resolveGitCommittish(ref: unknown): string {
if (typeof ref === 'string' && ref.length > 0) return ref; // explicit ref= wins
Comment thread
dawsontoth marked this conversation as resolved.
Outdated
if (process.env.GITHUB_SHA) return process.env.GITHUB_SHA;
try {
return runGit(['rev-parse', 'HEAD']);
} catch {
throw new Error('deploy by_ref: could not resolve HEAD — make at least one commit, or pass ref=<sha|tag>.');
}
}

function warnIfWorkingTreeDirty(): void {
try {
if (runGit(['status', '--porcelain'])) {
process.stderr.write(
'warning: working tree has uncommitted changes — the cluster deploys the committed (and pushed) commit, so those changes are NOT included.\n'
);
}
} catch {
// Not a git repo; resolveGitRepo/resolveGitCommittish will surface a clearer error.
}
}

function defaultProjectName(projectPath: string): string {
try {
const pkg = JSON.parse(fs.readFileSync(path.join(projectPath, 'package.json'), 'utf8'));
if (typeof pkg.name === 'string' && pkg.name.length > 0) return pkg.name.replace(/^@[^/]+\//, '');
} catch {
// No/invalid package.json — fall back to the directory name.
}
return path.basename(projectPath);
}

// Derived secret name for a git-host credential, mirroring the server's convention
// (`deploy.<component>.<host>`) so it matches whatever `harper deploy setup=true` sealed.
function deriveSecretName(component: string, key: string): string {
const keyPart = key
.trim()
.replace(/^https?:\/\//i, '')
.replace(/^\/\//, '')
.replace(/\/+$/, '')
.toLowerCase()
.replace(/[^\w.-]+/g, '_');
const componentPart = String(component).replace(/[^\w.-]+/g, '_');
return `deploy.${componentPart}.${keyPart}`;
}

const PREPARE_OPERATION: any = {
deploy_component: async (req) => {
if (req.package) {
return;
}

// Opt-in: deploy a pinned git commit by reference instead of packaging the working directory.
// Templates scaffold `by_ref=true`; without the flag the payload path below is unchanged.
if (req.by_ref || req.ref) {
Comment thread
dawsontoth marked this conversation as resolved.
const repo = resolveGitRepo();
const committish = resolveGitCommittish(req.ref);
warnIfWorkingTreeDirty();
if (!req.project) req.project = defaultProjectName(process.cwd());
// git+https (not ssh): a private clone is authenticated by a git-host token credential
// (#1799), which rides over HTTPS. A public repo needs no credential at all.
req.package = `git+https://github.com/${repo}.git#${committish}`;
// `credential=github.com` (or `credential=true`) attaches the sealed-token reference the
// cluster resolves at fetch time — provision it once with `harper deploy setup=true`.
const credentialHost =
req.credential === true
? 'github.com'
: typeof req.credential === 'string' && req.credential.length > 0
? req.credential
: undefined;
if (credentialHost && req.credentials === undefined) {
req.credentials = [{ host: credentialHost, secret: deriveSecretName(req.project, credentialHost) }];
}
process.stderr.write(`Deploying "${req.project}" by reference: ${req.package}\n`);
return;
}

const projectPath = process.cwd();
if (!req.project) req.project = path.basename(projectPath);
const packageOptions = {
Expand Down