From a2ac71140d227b214c0ccf4c8ee248ba09bb23fe Mon Sep 17 00:00:00 2001 From: Lyu Date: Sun, 4 Oct 2026 17:05:51 -0700 Subject: [PATCH] feat(compute ssh): --project to target a project without a link The console prints a setup command for a specific service, and the person copying it is usually not in a directory linked to that project: an unlinked directory opens a picker over every project (and saves the choice), or fails with no terminal. --project pins the target and writes no link. Branch defaults to main, the same value a link records, so a service set up both ways is not refused as a collision. Co-Authored-By: Claude Opus 5.5 --- src/commands/compute.ts | 5 +++-- src/index.ts | 1 + test/ssh-orchestration.test.ts | 16 ++++++++++++++++ 3 files changed, 20 insertions(+), 2 deletions(-) diff --git a/src/commands/compute.ts b/src/commands/compute.ts index 096cbf5..0adafe1 100644 --- a/src/commands/compute.ts +++ b/src/commands/compute.ts @@ -849,7 +849,7 @@ export const userKnownHostsPath = () => join(homedir(), '.ssh', 'known_hosts') /** The renewal-hook command prefix. ssh-config.ts appends the validated alias. */ export const ENSURE_CERT_COMMAND = 'insta __ssh-ensure-cert' -type SSHOpts = LifeOpts & { setup?: boolean; ensureCert?: string; json?: boolean } +type SSHOpts = LifeOpts & { setup?: boolean; ensureCert?: string; project?: string; json?: boolean } /** What an alias stands for. The renewal hook is handed nothing but the alias — * no positional argument, no guarantee the cwd is even a linked project — so @@ -1953,7 +1953,8 @@ export async function computeSSH(serviceName: string | undefined, opts: SSHOpts, const mint = deps.mint ?? mintCert const emit = deps.emit ?? info const api = await (deps.loadApi ?? ApiClient.load)() - const p = await (deps.loadProject ?? requireProject)() + // 'main' is what a link records by default; anything else makes assertAliasFree refuse a setup of the same service from a linked directory. + const p = opts.project !== undefined ? { projectId: opts.project, branch: 'main' } : await (deps.loadProject ?? requireProject)() const branch = opts.branch ?? p.branch const { services } = await api.request('GET', `/projects/${p.projectId}/services${q(branch)}`) const svc = resolveSoleService(services as ComputeRow[], 'compute', serviceName) diff --git a/src/index.ts b/src/index.ts index 4789eae..c1f8a8f 100644 --- a/src/index.ts +++ b/src/index.ts @@ -408,6 +408,7 @@ compute.command('ssh [service]') .description("Issue a short-lived SSH certificate for a compute service and print the command that uses it -- this command does NOT open the session itself, it makes `ssh` work. `--setup` does the one-time work: it generates a dedicated key under ~/.insta/ssh (your existing keys are never touched), has the platform sign a SHORT-LIVED certificate for it, adds one @cert-authority line to ~/.ssh/known_hosts so every region is trusted without per-node fingerprint prompts, and writes an ssh_config block AT THE TOP of ~/.ssh/config giving each compute service the alias `.insta`. After that it is plain `ssh api.insta`, scp and -L: the block renews that alias's certificate for you while OpenSSH parses the config. Needs an interactive login -- API keys are refused; use `insta compute exec` for one-shot commands from CI") .option('--setup', 'do the one-time client setup as well as issuing a certificate') .option('--ensure-cert ', 'renew the certificate for an alias such as api.insta if it is close to expiry, then exit (used by the ssh_config hook; silent by design)') + .option('--project ', 'project to use instead of the linked one; the directory is not linked (branch defaults to main)') .option('-b, --branch ', 'branch (default: linked)') .option('--json', 'machine-readable output') .action(guard((service, o) => computeCmd.computeSSH(service, o))) diff --git a/test/ssh-orchestration.test.ts b/test/ssh-orchestration.test.ts index 593f5a7..e0e5b9d 100644 --- a/test/ssh-orchestration.test.ts +++ b/test/ssh-orchestration.test.ts @@ -741,6 +741,22 @@ d('the branch the alias was set up on is the one it stays on', () => { expect(readAliasStore()['api.insta']).not.toHaveProperty('branch') }) + it('targets --project without reading the link, on main', async () => { + const paths: string[] = [] + const loadProject = async () => { throw new Error('the link was read despite --project') } + const { deps: d } = deps({ loadApi: recordingApi(paths), loadProject }) + await computeSSH('api', { project: 'proj-2' }, d) + expect(paths[0]).toBe('/projects/proj-2/services?branch=main') + expect(readAliasStore()['api.insta']).toMatchObject({ projectId: 'proj-2', branch: 'main' }) + }) + + it('combines --project with --branch', async () => { + const paths: string[] = [] + const { deps: d } = deps({ loadApi: recordingApi(paths), loadProject: project('proj-1', 'main') }) + await computeSSH('api', { project: 'proj-2', branch: 'feature-x' }, d) + expect(paths[0]).toBe('/projects/proj-2/services?branch=feature-x') + }) + it('refuses the same alias on a different branch of the same project', async () => { // The collision this field exists for, and the one case the branch is the // ONLY thing distinguishing: same project, same service name, two branches.