impel-cli 0.18.17 → 0.19.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,293 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+
4
+ import { tenantCliProfilePaths } from "../cliProfiles.js";
5
+ import { redactSecretText } from "../config.js";
6
+ import { runBuffer, runCapture, runInteractive, sleep } from "./process.js";
7
+ import { runPaths } from "./state.js";
8
+
9
+ const REMOTE_REPO = "/workspace/repo";
10
+ const REMOTE_BUNDLE = "/tmp/impel-remote-repo.bundle";
11
+ const SESSION_ID_RE = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,255}$/u;
12
+ const ENV_NAME_RE = /^[A-Za-z_][A-Za-z0-9_]*$/u;
13
+
14
+ function git(args, options = {}) {
15
+ return runCapture(process.env.IMPEL_REMOTE_GIT_BIN || "git", args, options).stdout.trim();
16
+ }
17
+
18
+ function sshArgs(state, command) {
19
+ return [state.alias, command];
20
+ }
21
+
22
+ function sshCapture(state, command, options = {}) {
23
+ return runCapture(process.env.IMPEL_REMOTE_SSH_BIN || "ssh", sshArgs(state, command), options);
24
+ }
25
+
26
+ function remoteShellQuote(value) {
27
+ return `'${String(value).replaceAll("'", `'"'"'`)}'`;
28
+ }
29
+
30
+ function remoteNode(state, source, payload) {
31
+ const evaluator = "eval(Buffer.from(process.argv[1],'base64').toString('utf8'))";
32
+ const encodedSource = Buffer.from(source, "utf8").toString("base64");
33
+ return sshCapture(state, `node -e ${remoteShellQuote(evaluator)} ${remoteShellQuote(encodedSource)}`, {
34
+ input: `${JSON.stringify(payload)}\n`,
35
+ });
36
+ }
37
+
38
+ function writePrivate(filePath, contents) {
39
+ fs.writeFileSync(filePath, contents, { mode: 0o600 });
40
+ try { fs.chmodSync(filePath, 0o600); } catch { /* Best effort on Windows. */ }
41
+ }
42
+
43
+ export function inspectRepository(requestedPath = process.cwd()) {
44
+ const requested = path.resolve(requestedPath);
45
+ const candidate = fs.realpathSync(requested);
46
+ const root = fs.realpathSync(git(["-C", candidate, "rev-parse", "--show-toplevel"]));
47
+ const head = git(["-C", root, "rev-parse", "HEAD"]);
48
+ const branchResult = runCapture(process.env.IMPEL_REMOTE_GIT_BIN || "git", [
49
+ "-C", root, "symbolic-ref", "--quiet", "--short", "HEAD",
50
+ ], { allowFailure: true });
51
+ const originResult = runCapture(process.env.IMPEL_REMOTE_GIT_BIN || "git", [
52
+ "-C", root, "remote", "get-url", "origin",
53
+ ], { allowFailure: true });
54
+ const userNameResult = runCapture(process.env.IMPEL_REMOTE_GIT_BIN || "git", [
55
+ "-C", root, "config", "--get", "user.name",
56
+ ], { allowFailure: true });
57
+ const userEmailResult = runCapture(process.env.IMPEL_REMOTE_GIT_BIN || "git", [
58
+ "-C", root, "config", "--get", "user.email",
59
+ ], { allowFailure: true });
60
+ const relativeProjectPath = path.relative(root, candidate) || ".";
61
+ if (relativeProjectPath === ".." || relativeProjectPath.startsWith(`..${path.sep}`)) {
62
+ throw new Error(`${candidate} is not inside its Git repository`);
63
+ }
64
+ return {
65
+ root,
66
+ head,
67
+ branch: branchResult.status === 0 ? branchResult.stdout.trim() : null,
68
+ origin: originResult.status === 0 ? originResult.stdout.trim() : null,
69
+ userName: userNameResult.status === 0 ? userNameResult.stdout.trim() : null,
70
+ userEmail: userEmailResult.status === 0 ? userEmailResult.stdout.trim() : null,
71
+ relativeProjectPath: relativeProjectPath.split(path.sep).join("/"),
72
+ remoteProjectPath: relativeProjectPath === "."
73
+ ? REMOTE_REPO
74
+ : `${REMOTE_REPO}/${relativeProjectPath.split(path.sep).join("/")}`,
75
+ };
76
+ }
77
+
78
+ export function createRepositoryTransfer(state, repository) {
79
+ const paths = runPaths(state.runId);
80
+ runCapture(process.env.IMPEL_REMOTE_GIT_BIN || "git", [
81
+ "-C", repository.root, "bundle", "create", paths.bundle, "--all",
82
+ ]);
83
+
84
+ const deleted = runBuffer(process.env.IMPEL_REMOTE_GIT_BIN || "git", [
85
+ "-C", repository.root, "ls-files", "--deleted", "-z",
86
+ ]).stdout;
87
+ writePrivate(paths.deletedList, deleted);
88
+ const deletedPaths = new Set(splitNullBuffer(deleted).map((entry) => entry.toString("base64")));
89
+ const listedFiles = runBuffer(process.env.IMPEL_REMOTE_GIT_BIN || "git", [
90
+ "-C", repository.root, "ls-files", "--cached", "--others", "--exclude-standard", "-z",
91
+ ]).stdout;
92
+ const files = joinNullBuffer(splitNullBuffer(listedFiles).filter(
93
+ (entry) => !deletedPaths.has(entry.toString("base64")),
94
+ ));
95
+ writePrivate(paths.fileList, files);
96
+ runCapture(process.env.IMPEL_REMOTE_TAR_BIN || "tar", [
97
+ "-cf", paths.archive,
98
+ "--no-xattrs",
99
+ "--null",
100
+ "-T", paths.fileList,
101
+ ], { cwd: repository.root });
102
+ return paths;
103
+ }
104
+
105
+ function splitNullBuffer(buffer) {
106
+ const entries = [];
107
+ let start = 0;
108
+ while (start < buffer.length) {
109
+ const end = buffer.indexOf(0, start);
110
+ if (end === -1) throw new Error("Git returned a malformed NUL-delimited path list");
111
+ if (end > start) entries.push(buffer.subarray(start, end));
112
+ start = end + 1;
113
+ }
114
+ return entries;
115
+ }
116
+
117
+ function joinNullBuffer(entries) {
118
+ if (entries.length === 0) return Buffer.alloc(0);
119
+ return Buffer.concat(entries.flatMap((entry) => [entry, Buffer.from([0])]));
120
+ }
121
+
122
+ export async function waitForSsh(state, timeoutSeconds = 120) {
123
+ const deadline = Date.now() + timeoutSeconds * 1000;
124
+ let lastError = "SSH did not accept a connection";
125
+ while (Date.now() < deadline) {
126
+ const result = sshCapture(state, "true", { allowFailure: true });
127
+ if (result.status === 0) return;
128
+ lastError = redactSecretText(result.stderr || lastError).trim();
129
+ await sleep(Number(process.env.IMPEL_REMOTE_SSH_POLL_MS || 2000));
130
+ }
131
+ throw new Error(`remote runner did not become SSH-ready within ${timeoutSeconds} seconds: ${lastError}`);
132
+ }
133
+
134
+ export function uploadRemoteConfiguration(state, {
135
+ config,
136
+ credential,
137
+ tenant,
138
+ environmentNames = [],
139
+ }) {
140
+ const remoteConfig = {
141
+ pat: credential.token,
142
+ gatewayUrl: config.gatewayUrl,
143
+ appUrl: config.appUrl,
144
+ ...(config.sessionsUrl ? { sessionsUrl: config.sessionsUrl } : {}),
145
+ tenantId: tenant.tenantId,
146
+ tenantName: tenant.tenantName,
147
+ ...(tenant.productAccess ? { productAccess: tenant.productAccess } : {}),
148
+ scopes: [...credential.scopes],
149
+ remoteRunId: state.runId,
150
+ updatedAt: new Date().toISOString(),
151
+ };
152
+ const selectedEnvironment = {
153
+ IMPEL_REMOTE_RUN_ID: state.runId,
154
+ IMPEL_REMOTE_DANGEROUS_MODE: "1",
155
+ };
156
+ for (const name of environmentNames) {
157
+ if (!ENV_NAME_RE.test(name)) throw new Error(`invalid environment variable name ${JSON.stringify(name)}`);
158
+ if (!Object.hasOwn(process.env, name)) throw new Error(`environment variable ${name} is not set`);
159
+ selectedEnvironment[name] = process.env[name];
160
+ }
161
+ const shellQuote = (value) => `'${String(value).replaceAll("'", `'"'"'`)}'`;
162
+ const environmentFile = Object.entries(selectedEnvironment)
163
+ .map(([name, value]) => `export ${name}=${shellQuote(value)}`)
164
+ .join("\n") + "\n";
165
+ const profileRoot = `/home/agent/.config/impel/cli/tenants/${tenant.tenantId}`;
166
+ const claudeSettings = {
167
+ permissions: { defaultMode: "bypassPermissions" },
168
+ sandbox: { enabled: false },
169
+ };
170
+ const files = [
171
+ { path: "/home/agent/.config/impel/config.json", contents: `${JSON.stringify(remoteConfig, null, 2)}\n` },
172
+ { path: "/home/agent/.config/impel/remote/env.sh", contents: environmentFile },
173
+ {
174
+ path: "/home/agent/.config/impel/remote/run.json",
175
+ contents: `${JSON.stringify({
176
+ runId: state.runId,
177
+ credentialTokenId: credential.tokenId,
178
+ expiresAt: state.expiresAt,
179
+ }, null, 2)}\n`,
180
+ },
181
+ { path: `${profileRoot}/claude/settings.json`, contents: `${JSON.stringify(claudeSettings, null, 2)}\n` },
182
+ ];
183
+ remoteNode(state, String.raw`
184
+ const fs = require("node:fs");
185
+ const path = require("node:path");
186
+ const payload = JSON.parse(fs.readFileSync(0, "utf8"));
187
+ for (const file of payload.files) {
188
+ const target = path.resolve(file.path);
189
+ if (!target.startsWith("/home/agent/.config/impel/")) throw new Error("refusing remote path");
190
+ fs.mkdirSync(path.dirname(target), { recursive: true, mode: 0o700 });
191
+ fs.writeFileSync(target, file.contents, { mode: 0o600 });
192
+ fs.chmodSync(target, 0o600);
193
+ }
194
+ `, { files });
195
+ }
196
+
197
+ export function uploadRepository(state, repository) {
198
+ const paths = runPaths(state.runId);
199
+ runInteractive(process.env.IMPEL_REMOTE_SSH_BIN || "ssh", sshArgs(state, `cat > ${REMOTE_BUNDLE}`), {
200
+ inputFile: paths.bundle,
201
+ });
202
+ remoteNode(state, String.raw`
203
+ const fs = require("node:fs");
204
+ const cp = require("node:child_process");
205
+ const payload = JSON.parse(fs.readFileSync(0, "utf8"));
206
+ const repo = "/workspace/repo";
207
+ fs.rmSync(repo, { recursive: true, force: true });
208
+ fs.mkdirSync("/workspace", { recursive: true });
209
+ cp.execFileSync("git", ["clone", "/tmp/impel-remote-repo.bundle", repo], { stdio: "inherit" });
210
+ cp.execFileSync("git", ["-C", repo, "checkout", "--detach", payload.head], { stdio: "inherit" });
211
+ if (payload.branch) cp.execFileSync("git", ["-C", repo, "checkout", "-B", payload.branch, payload.head], { stdio: "inherit" });
212
+ if (payload.origin) cp.execFileSync("git", ["-C", repo, "remote", "set-url", "origin", payload.origin], { stdio: "inherit" });
213
+ if (payload.userName) cp.execFileSync("git", ["-C", repo, "config", "user.name", payload.userName]);
214
+ if (payload.userEmail) cp.execFileSync("git", ["-C", repo, "config", "user.email", payload.userEmail]);
215
+ `, repository);
216
+
217
+ runInteractive(process.env.IMPEL_REMOTE_SSH_BIN || "ssh", sshArgs(state, `tar -xf - -C ${REMOTE_REPO}`), {
218
+ inputFile: paths.archive,
219
+ });
220
+ const deleted = fs.readFileSync(paths.deletedList);
221
+ remoteNode(state, String.raw`
222
+ const fs = require("node:fs");
223
+ const path = require("node:path");
224
+ const payload = JSON.parse(fs.readFileSync(0, "utf8"));
225
+ const root = "/workspace/repo";
226
+ for (const relative of payload.deleted) {
227
+ const target = path.resolve(root, relative);
228
+ if (target !== root && !target.startsWith(root + path.sep)) throw new Error("unsafe deleted path");
229
+ fs.rmSync(target, { recursive: true, force: true });
230
+ }
231
+ fs.rmSync("/tmp/impel-remote-repo.bundle", { force: true });
232
+ `, { deleted: deleted.toString("utf8").split("\0").filter(Boolean) });
233
+ }
234
+
235
+ export function prepareWorkspace(state, { installMode = "auto", setupCommand = null } = {}) {
236
+ if (!new Set(["auto", "skip"]).has(installMode)) throw new Error("--install must be auto or skip");
237
+ if (installMode === "auto") {
238
+ runInteractive(process.env.IMPEL_REMOTE_SSH_BIN || "ssh", ["-t", state.alias, "impel-remote-prepare", REMOTE_REPO]);
239
+ }
240
+ if (setupCommand) {
241
+ const encodedCommand = Buffer.from(setupCommand, "utf8").toString("base64");
242
+ const source = "const cp=require('node:child_process');const command=Buffer.from(process.argv[1],'base64').toString('utf8');const child=cp.spawnSync('/bin/bash',['-lc',command],{cwd:'/workspace/repo',stdio:'inherit'});process.exit(Number.isInteger(child.status)?child.status:1)";
243
+ runInteractive(process.env.IMPEL_REMOTE_SSH_BIN || "ssh", [
244
+ "-t", state.alias, `node -e ${remoteShellQuote(source)} ${remoteShellQuote(encodedCommand)}`,
245
+ ]);
246
+ }
247
+ }
248
+
249
+ function collectSessionFiles(root, sessionId) {
250
+ const files = [];
251
+ const visit = (directory, relative = "") => {
252
+ for (const entry of fs.readdirSync(directory, { withFileTypes: true })) {
253
+ const childRelative = relative ? path.join(relative, entry.name) : entry.name;
254
+ const child = path.join(directory, entry.name);
255
+ if (entry.isSymbolicLink()) continue;
256
+ if (entry.isDirectory()) visit(child, childRelative);
257
+ else if (entry.isFile() && childRelative.includes(sessionId)) files.push(childRelative);
258
+ }
259
+ };
260
+ if (fs.existsSync(root)) visit(root);
261
+ return files;
262
+ }
263
+
264
+ export function transferSessionCheckpoint(state, { provider, sessionId, tenantId }) {
265
+ if (!SESSION_ID_RE.test(String(sessionId || ""))) throw new Error("--session must be a safe provider session id");
266
+ const profiles = tenantCliProfilePaths(tenantId);
267
+ const localRoot = provider === "claude" ? profiles.claudeConfigDir : profiles.codexHome;
268
+ const remoteRoot = provider === "claude"
269
+ ? `/home/agent/.config/impel/cli/tenants/${tenantId}/claude`
270
+ : `/home/agent/.config/impel/cli/tenants/${tenantId}/codex`;
271
+ const files = collectSessionFiles(localRoot, sessionId);
272
+ if (files.length === 0) {
273
+ throw new Error(`could not find ${provider} session ${sessionId} in the selected Impel CLI profile`);
274
+ }
275
+ const checkpointList = path.join(runPaths(state.runId).root, `checkpoint-${provider}.list`);
276
+ const checkpointArchive = path.join(runPaths(state.runId).root, `checkpoint-${provider}.tar`);
277
+ writePrivate(checkpointList, Buffer.from(`${files.join("\0")}\0`, "utf8"));
278
+ try {
279
+ runCapture(process.env.IMPEL_REMOTE_TAR_BIN || "tar", [
280
+ "-cf", checkpointArchive, "--no-xattrs", "--null", "-T", checkpointList,
281
+ ], { cwd: localRoot });
282
+ sshCapture(state, `mkdir -p ${remoteRoot}`);
283
+ runInteractive(process.env.IMPEL_REMOTE_SSH_BIN || "ssh", sshArgs(state, `tar -xf - -C ${remoteRoot}`), {
284
+ inputFile: checkpointArchive,
285
+ });
286
+ } finally {
287
+ fs.rmSync(checkpointList, { force: true });
288
+ fs.rmSync(checkpointArchive, { force: true });
289
+ }
290
+ return files.length;
291
+ }
292
+
293
+ export const REMOTE_PROJECT_ROOT = REMOTE_REPO;