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.
- package/README.md +62 -0
- package/package.json +1 -1
- package/src/cli.js +5 -0
- package/src/commands/pat.js +2 -2
- package/src/commands/remote.js +559 -0
- package/src/remote/aws.js +142 -0
- package/src/remote/process.js +131 -0
- package/src/remote/state.js +202 -0
- package/src/remote/transfer.js +293 -0
package/README.md
CHANGED
|
@@ -180,6 +180,68 @@ impel claude --model opus
|
|
|
180
180
|
impel codex exec "review this repository"
|
|
181
181
|
```
|
|
182
182
|
|
|
183
|
+
## Remote Fargate sessions
|
|
184
|
+
|
|
185
|
+
Start a disposable AWS Fargate runner for the current Git repository:
|
|
186
|
+
|
|
187
|
+
```sh
|
|
188
|
+
impel remote up --provider codex
|
|
189
|
+
```
|
|
190
|
+
|
|
191
|
+
The command creates a one-day, provider-scoped disposable PAT, starts the
|
|
192
|
+
TTL-bounded task, installs a concrete SSH alias, transfers Git history plus the
|
|
193
|
+
tracked and nonignored working tree, restores recognized Node/Python lockfile
|
|
194
|
+
dependencies, and uploads the disposable credential only through encrypted
|
|
195
|
+
SSH. The user's stored PAT, ignored files, SSH agent, Keychain, and environment
|
|
196
|
+
are not copied. Select variables explicitly when they are genuinely required:
|
|
197
|
+
|
|
198
|
+
```sh
|
|
199
|
+
impel remote up --provider claude --env API_HOST,FEATURE_FLAG
|
|
200
|
+
```
|
|
201
|
+
|
|
202
|
+
Use the remote session from a terminal:
|
|
203
|
+
|
|
204
|
+
```sh
|
|
205
|
+
impel remote status
|
|
206
|
+
impel remote attach --provider codex
|
|
207
|
+
impel remote attach --provider claude
|
|
208
|
+
```
|
|
209
|
+
|
|
210
|
+
For Codex Desktop on macOS, open the native connection flow:
|
|
211
|
+
|
|
212
|
+
```sh
|
|
213
|
+
impel remote attach --provider codex --desktop
|
|
214
|
+
```
|
|
215
|
+
|
|
216
|
+
Save `/workspace/repo` as the remote project. In an existing Codex chat, use
|
|
217
|
+
the footer's run-location picker and choose the generated SSH host. Codex then
|
|
218
|
+
uses its supported SSH app-server transport to move the chat and Git state and
|
|
219
|
+
stream prompts, approvals, tools, diffs, and output in the local app.
|
|
220
|
+
|
|
221
|
+
Claude Desktop can open a new native SSH session on the same alias. Claude does
|
|
222
|
+
not expose an arbitrary-host equivalent of Codex's exact chat handoff. To move
|
|
223
|
+
an existing Impel Claude/Codex CLI session, checkpoint it and create a remote
|
|
224
|
+
continuation or fork:
|
|
225
|
+
|
|
226
|
+
```sh
|
|
227
|
+
impel remote dispatch --provider claude --session <session-id> --fork
|
|
228
|
+
impel remote dispatch --provider codex --session <session-id> --fork
|
|
229
|
+
```
|
|
230
|
+
|
|
231
|
+
Remote vendor commands run in dangerous/bypass mode as the unprivileged
|
|
232
|
+
`agent` user; the disposable Fargate task is the isolation boundary. Existing
|
|
233
|
+
Impel lifecycle hooks continue mirroring remote transcripts to
|
|
234
|
+
`impel-sessions`. Stop the task and revoke its credential when finished:
|
|
235
|
+
|
|
236
|
+
```sh
|
|
237
|
+
impel remote down --yes
|
|
238
|
+
```
|
|
239
|
+
|
|
240
|
+
The current lifecycle client requires macOS or Linux, OpenSSH, the AWS CLI, the
|
|
241
|
+
AWS Session Manager plugin, and permission to use the deployed
|
|
242
|
+
`impel-remote-dev` stack. Use `impel remote help` for region, profile, TTL,
|
|
243
|
+
dependency, and setup overrides.
|
|
244
|
+
|
|
183
245
|
Changing the selected tenant does not install, remove, or rewrite any other
|
|
184
246
|
tenant. It changes only the default used by CLI launches and selected-tenant
|
|
185
247
|
workspace commands.
|
package/package.json
CHANGED
package/src/cli.js
CHANGED
|
@@ -20,6 +20,7 @@ import { cmdUpdate } from "./commands/update.js";
|
|
|
20
20
|
import { cmdNuke } from "./commands/nuke.js";
|
|
21
21
|
import { cmdExperimental } from "./commands/experimental.js";
|
|
22
22
|
import { cmdConverge } from "./commands/converge.js";
|
|
23
|
+
import { cmdRemote } from "./commands/remote.js";
|
|
23
24
|
import { ensureMacDeveloperTools } from "./macDeveloperTools.js";
|
|
24
25
|
import { refuseElevatedMacExecution } from "./privileges.js";
|
|
25
26
|
|
|
@@ -34,6 +35,7 @@ Get started:
|
|
|
34
35
|
Work:
|
|
35
36
|
impel claude [args...] Launch Claude Code with an isolated Impel profile
|
|
36
37
|
impel codex [args...] Launch Codex with an isolated Impel profile
|
|
38
|
+
impel remote up|status|attach|down Run a local repository on AWS Fargate
|
|
37
39
|
impel tenant list List accessible organizations
|
|
38
40
|
impel tenant current Show the CLI's current organization
|
|
39
41
|
impel tenant use <org> Select the organization for CLI launches
|
|
@@ -120,6 +122,9 @@ export async function main(argv) {
|
|
|
120
122
|
case "codex":
|
|
121
123
|
return cmdLaunch(cmd, rest);
|
|
122
124
|
|
|
125
|
+
case "remote":
|
|
126
|
+
return cmdRemote(rest);
|
|
127
|
+
|
|
123
128
|
case "status":
|
|
124
129
|
return cmdStatus();
|
|
125
130
|
|
package/src/commands/pat.js
CHANGED
|
@@ -302,7 +302,7 @@ async function requestControlPlane({ appUrl, currentPat, path, method, body, fea
|
|
|
302
302
|
return payload;
|
|
303
303
|
}
|
|
304
304
|
|
|
305
|
-
async function createPat({ appUrl, currentPat, body }) {
|
|
305
|
+
export async function createPat({ appUrl, currentPat, body }) {
|
|
306
306
|
const payload = await requestControlPlane({
|
|
307
307
|
appUrl,
|
|
308
308
|
currentPat,
|
|
@@ -318,7 +318,7 @@ async function createPat({ appUrl, currentPat, body }) {
|
|
|
318
318
|
return created;
|
|
319
319
|
}
|
|
320
320
|
|
|
321
|
-
async function revokePat({ appUrl, currentPat, tokenId }) {
|
|
321
|
+
export async function revokePat({ appUrl, currentPat, tokenId }) {
|
|
322
322
|
await requestControlPlane({
|
|
323
323
|
appUrl,
|
|
324
324
|
currentPat,
|
|
@@ -0,0 +1,559 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
|
|
3
|
+
import { parseFlags } from "../args.js";
|
|
4
|
+
import {
|
|
5
|
+
loadConfig,
|
|
6
|
+
normalizeGatewayUrl,
|
|
7
|
+
redactSecretText,
|
|
8
|
+
resolveDefaultAppUrl,
|
|
9
|
+
resolveDefaultGateway,
|
|
10
|
+
} from "../config.js";
|
|
11
|
+
import { createPat, revokePat } from "./pat.js";
|
|
12
|
+
import {
|
|
13
|
+
assertProviderScopes,
|
|
14
|
+
ensureTenantSelection,
|
|
15
|
+
PAT_SCOPE_CLAUDE,
|
|
16
|
+
PAT_SCOPE_CODEX,
|
|
17
|
+
} from "../tenants.js";
|
|
18
|
+
import {
|
|
19
|
+
awsContext,
|
|
20
|
+
describeTask,
|
|
21
|
+
readStack,
|
|
22
|
+
startSshProxy,
|
|
23
|
+
startTask,
|
|
24
|
+
stopTask,
|
|
25
|
+
waitForTaskReady,
|
|
26
|
+
} from "../remote/aws.js";
|
|
27
|
+
import { requireExecutables, runCapture, runInteractive } from "../remote/process.js";
|
|
28
|
+
import {
|
|
29
|
+
initializeRun,
|
|
30
|
+
installSshAlias,
|
|
31
|
+
listRunStates,
|
|
32
|
+
newRunId,
|
|
33
|
+
readRunState,
|
|
34
|
+
removeRunSecrets,
|
|
35
|
+
removeSshAlias,
|
|
36
|
+
resolveRunId,
|
|
37
|
+
runPaths,
|
|
38
|
+
sshAlias,
|
|
39
|
+
writeRunState,
|
|
40
|
+
} from "../remote/state.js";
|
|
41
|
+
import {
|
|
42
|
+
createRepositoryTransfer,
|
|
43
|
+
inspectRepository,
|
|
44
|
+
prepareWorkspace,
|
|
45
|
+
transferSessionCheckpoint,
|
|
46
|
+
uploadRemoteConfiguration,
|
|
47
|
+
uploadRepository,
|
|
48
|
+
waitForSsh,
|
|
49
|
+
} from "../remote/transfer.js";
|
|
50
|
+
|
|
51
|
+
const DEFAULT_REGION = "eu-west-2";
|
|
52
|
+
const DEFAULT_STACK = "impel-remote-dev";
|
|
53
|
+
const DEFAULT_TTL_SECONDS = 3600;
|
|
54
|
+
const DEFAULT_STARTUP_TIMEOUT_SECONDS = 300;
|
|
55
|
+
const PROVIDERS = new Set(["claude", "codex"]);
|
|
56
|
+
|
|
57
|
+
const HELP = `impel remote - run an Impel session on an isolated AWS Fargate runner
|
|
58
|
+
|
|
59
|
+
Usage:
|
|
60
|
+
impel remote up [path] [options]
|
|
61
|
+
impel remote status [run-id] [--json]
|
|
62
|
+
impel remote attach [run-id] [--provider codex|claude] [--desktop]
|
|
63
|
+
impel remote dispatch [path] --provider codex|claude --session <id> [--fork]
|
|
64
|
+
impel remote proxy <run-id> --port <port>
|
|
65
|
+
impel remote down [run-id] --yes
|
|
66
|
+
impel remote down --all --yes
|
|
67
|
+
|
|
68
|
+
Lifecycle options:
|
|
69
|
+
--provider <name> Provider prepared for the run. Default: codex.
|
|
70
|
+
--ttl <seconds> Task lifetime from 300 through 86400. Default: 3600.
|
|
71
|
+
--region <region> AWS region. Default: eu-west-2.
|
|
72
|
+
--profile <profile> AWS CLI profile. Defaults to AWS_PROFILE/current AWS config.
|
|
73
|
+
--stack <name> CloudFormation stack. Default: impel-remote-dev.
|
|
74
|
+
--install auto|skip Restore recognized lockfile dependencies. Default: auto.
|
|
75
|
+
--setup <command> Additional explicit setup command to run after transfer.
|
|
76
|
+
--env <csv> Explicit environment variable names to copy; none by default.
|
|
77
|
+
--timeout <seconds> Runner startup timeout from 30 through 900. Default: 300.
|
|
78
|
+
--json Emit machine-readable state where supported.
|
|
79
|
+
|
|
80
|
+
Attach options:
|
|
81
|
+
--session <id> Resume a transferred provider session.
|
|
82
|
+
--fork Fork instead of continuing the transferred session.
|
|
83
|
+
--desktop Open the native desktop connection flow instead of a terminal.
|
|
84
|
+
|
|
85
|
+
The live UI uses native SSH: Codex Desktop starts remote codex app-server and can
|
|
86
|
+
hand off an existing chat and Git state. Claude Desktop can start an SSH session;
|
|
87
|
+
existing Claude sessions use dispatch/resume because Claude has no arbitrary-host
|
|
88
|
+
desktop handoff API. Credentials, ignored files, SSH agents, and environment
|
|
89
|
+
variables are never copied implicitly.
|
|
90
|
+
`;
|
|
91
|
+
|
|
92
|
+
class RemoteCommandError extends Error {}
|
|
93
|
+
|
|
94
|
+
function fail(message) {
|
|
95
|
+
throw new RemoteCommandError(message);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function integerFlag(value, name, fallback, minimum, maximum) {
|
|
99
|
+
if (value === undefined) return fallback;
|
|
100
|
+
if (!/^\d+$/u.test(String(value))) fail(`impel remote: --${name} must be an integer from ${minimum} through ${maximum}`);
|
|
101
|
+
const number = Number(value);
|
|
102
|
+
if (!Number.isSafeInteger(number) || number < minimum || number > maximum) {
|
|
103
|
+
fail(`impel remote: --${name} must be an integer from ${minimum} through ${maximum}`);
|
|
104
|
+
}
|
|
105
|
+
return number;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function providerFlag(value, fallback = "codex") {
|
|
109
|
+
const provider = String(value || fallback).trim().toLowerCase();
|
|
110
|
+
if (!PROVIDERS.has(provider)) fail("impel remote: --provider must be codex or claude");
|
|
111
|
+
return provider;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function rejectFlags(flags, allowed, action) {
|
|
115
|
+
const unsupported = Object.keys(flags).find((name) => !allowed.has(name));
|
|
116
|
+
if (unsupported) fail(`impel remote ${action}: unknown option --${unsupported}`);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function rejectMissingFlagValues(flags, spec, action) {
|
|
120
|
+
const missing = Object.keys(flags).find((name) => spec[name]?.type === "string" && flags[name] === undefined);
|
|
121
|
+
if (missing) fail(`impel remote ${action}: --${missing} requires a value`);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function lifecycleSpec() {
|
|
125
|
+
return {
|
|
126
|
+
env: { type: "string" },
|
|
127
|
+
help: { type: "boolean" },
|
|
128
|
+
install: { type: "string" },
|
|
129
|
+
json: { type: "boolean" },
|
|
130
|
+
profile: { type: "string" },
|
|
131
|
+
provider: { type: "string" },
|
|
132
|
+
region: { type: "string" },
|
|
133
|
+
session: { type: "string" },
|
|
134
|
+
setup: { type: "string" },
|
|
135
|
+
stack: { type: "string" },
|
|
136
|
+
timeout: { type: "string" },
|
|
137
|
+
ttl: { type: "string" },
|
|
138
|
+
fork: { type: "boolean" },
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function requiredConfig() {
|
|
143
|
+
const config = loadConfig();
|
|
144
|
+
if (!config?.pat) fail("impel remote: not authenticated; run `impel setup` first");
|
|
145
|
+
return {
|
|
146
|
+
...config,
|
|
147
|
+
appUrl: normalizeGatewayUrl(config.appUrl || resolveDefaultAppUrl()),
|
|
148
|
+
gatewayUrl: normalizeGatewayUrl(config.gatewayUrl || resolveDefaultGateway()),
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function credentialScopes(provider) {
|
|
153
|
+
return provider === "claude" ? [PAT_SCOPE_CLAUDE] : [PAT_SCOPE_CODEX];
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function cleanTransferArtifacts(runId) {
|
|
157
|
+
const paths = runPaths(runId);
|
|
158
|
+
for (const target of [paths.bundle, paths.fileList, paths.archive, paths.deletedList]) {
|
|
159
|
+
try { fs.rmSync(target, { force: true }); } catch { /* Best effort after a successful upload. */ }
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
async function revokeRunCredential(config, state, { tolerateFailure = false } = {}) {
|
|
164
|
+
if (!state?.credential?.tokenId || state.credential.revokedAt) return state;
|
|
165
|
+
try {
|
|
166
|
+
await revokePat({
|
|
167
|
+
appUrl: normalizeGatewayUrl(config.appUrl || resolveDefaultAppUrl()),
|
|
168
|
+
currentPat: config.pat,
|
|
169
|
+
tokenId: state.credential.tokenId,
|
|
170
|
+
});
|
|
171
|
+
return writeRunState({
|
|
172
|
+
...state,
|
|
173
|
+
credential: { ...state.credential, revokedAt: new Date().toISOString() },
|
|
174
|
+
});
|
|
175
|
+
} catch (error) {
|
|
176
|
+
if (!tolerateFailure) throw error;
|
|
177
|
+
return writeRunState({
|
|
178
|
+
...state,
|
|
179
|
+
credential: {
|
|
180
|
+
...state.credential,
|
|
181
|
+
revokeError: redactSecretText(error?.message || error),
|
|
182
|
+
},
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
async function createRemoteRun(options) {
|
|
188
|
+
if (process.platform === "win32") {
|
|
189
|
+
fail("impel remote lifecycle is not yet supported on Windows; use macOS or Linux with OpenSSH and the AWS Session Manager plugin");
|
|
190
|
+
}
|
|
191
|
+
requireExecutables(["aws", "git", "session-manager-plugin", "ssh", "ssh-keygen", "tar"]);
|
|
192
|
+
const provider = providerFlag(options.provider);
|
|
193
|
+
const ttlSeconds = integerFlag(options.ttl, "ttl", DEFAULT_TTL_SECONDS, 300, 86400);
|
|
194
|
+
const startupTimeout = integerFlag(options.timeout, "timeout", DEFAULT_STARTUP_TIMEOUT_SECONDS, 30, 900);
|
|
195
|
+
const config = requiredConfig();
|
|
196
|
+
const selection = await ensureTenantSelection(config, { refresh: !Array.isArray(config.scopes) });
|
|
197
|
+
assertProviderScopes(selection.scopes, [provider], { requireLive: true });
|
|
198
|
+
const repository = inspectRepository(options.path || process.cwd());
|
|
199
|
+
const runId = newRunId();
|
|
200
|
+
const paths = initializeRun(runId);
|
|
201
|
+
const createdAt = new Date().toISOString();
|
|
202
|
+
const expiresAt = new Date(Date.now() + ttlSeconds * 1000).toISOString();
|
|
203
|
+
const context = awsContext({
|
|
204
|
+
region: options.region || process.env.AWS_REGION || process.env.AWS_DEFAULT_REGION || DEFAULT_REGION,
|
|
205
|
+
profile: options.profile || process.env.AWS_PROFILE || null,
|
|
206
|
+
stackName: options.stack || DEFAULT_STACK,
|
|
207
|
+
});
|
|
208
|
+
let state = writeRunState({
|
|
209
|
+
runId,
|
|
210
|
+
alias: sshAlias(runId),
|
|
211
|
+
status: "preparing",
|
|
212
|
+
provider,
|
|
213
|
+
createdAt,
|
|
214
|
+
expiresAt,
|
|
215
|
+
ttlSeconds,
|
|
216
|
+
aws: { ...context },
|
|
217
|
+
repository,
|
|
218
|
+
});
|
|
219
|
+
let credential = null;
|
|
220
|
+
let taskStarted = false;
|
|
221
|
+
try {
|
|
222
|
+
runCapture(process.env.IMPEL_REMOTE_SSH_KEYGEN_BIN || "ssh-keygen", [
|
|
223
|
+
"-q", "-t", "ed25519", "-N", "", "-C", `impel-remote-${runId}`, "-f", paths.privateKey,
|
|
224
|
+
]);
|
|
225
|
+
fs.writeFileSync(paths.knownHosts, "", { mode: 0o600 });
|
|
226
|
+
createRepositoryTransfer(state, repository);
|
|
227
|
+
|
|
228
|
+
credential = await createPat({
|
|
229
|
+
appUrl: config.appUrl,
|
|
230
|
+
currentPat: config.pat,
|
|
231
|
+
body: {
|
|
232
|
+
orgId: selection.tenantId,
|
|
233
|
+
label: `Remote · ${runId}`,
|
|
234
|
+
scopes: credentialScopes(provider),
|
|
235
|
+
ttlDays: 1,
|
|
236
|
+
},
|
|
237
|
+
});
|
|
238
|
+
state = writeRunState({
|
|
239
|
+
...state,
|
|
240
|
+
credential: {
|
|
241
|
+
tokenId: credential.tokenId,
|
|
242
|
+
expiresAt: credential.expiresAt === null ? null : new Date(credential.expiresAt).toISOString(),
|
|
243
|
+
scopes: credential.scopes,
|
|
244
|
+
},
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
const outputs = readStack(context);
|
|
248
|
+
const publicKey = fs.readFileSync(paths.publicKey, "utf8");
|
|
249
|
+
const started = startTask(context, { outputs, publicKey, ttlSeconds, runId });
|
|
250
|
+
taskStarted = true;
|
|
251
|
+
state = writeRunState({
|
|
252
|
+
...state,
|
|
253
|
+
status: "starting",
|
|
254
|
+
aws: {
|
|
255
|
+
...state.aws,
|
|
256
|
+
clusterName: started.clusterName,
|
|
257
|
+
taskArn: started.taskArn,
|
|
258
|
+
taskDefinitionArn: outputs.TaskDefinitionArn,
|
|
259
|
+
},
|
|
260
|
+
});
|
|
261
|
+
const ready = await waitForTaskReady(context, state, startupTimeout);
|
|
262
|
+
state = writeRunState({
|
|
263
|
+
...state,
|
|
264
|
+
status: "bootstrapping",
|
|
265
|
+
aws: { ...state.aws, ...ready },
|
|
266
|
+
});
|
|
267
|
+
installSshAlias(state);
|
|
268
|
+
await waitForSsh(state, Math.min(startupTimeout, 180));
|
|
269
|
+
uploadRemoteConfiguration(state, {
|
|
270
|
+
config,
|
|
271
|
+
credential,
|
|
272
|
+
tenant: selection,
|
|
273
|
+
environmentNames: String(options.env || "").split(",").map((name) => name.trim()).filter(Boolean),
|
|
274
|
+
});
|
|
275
|
+
uploadRepository(state, repository);
|
|
276
|
+
if (options.session) {
|
|
277
|
+
transferSessionCheckpoint(state, {
|
|
278
|
+
provider,
|
|
279
|
+
sessionId: options.session,
|
|
280
|
+
tenantId: selection.tenantId,
|
|
281
|
+
});
|
|
282
|
+
}
|
|
283
|
+
prepareWorkspace(state, {
|
|
284
|
+
installMode: options.install || "auto",
|
|
285
|
+
setupCommand: options.setup || null,
|
|
286
|
+
});
|
|
287
|
+
state = writeRunState({
|
|
288
|
+
...state,
|
|
289
|
+
status: "running",
|
|
290
|
+
readyAt: new Date().toISOString(),
|
|
291
|
+
session: options.session ? { provider, id: options.session, fork: options.fork === true } : null,
|
|
292
|
+
});
|
|
293
|
+
cleanTransferArtifacts(runId);
|
|
294
|
+
return state;
|
|
295
|
+
} catch (error) {
|
|
296
|
+
let cleanupError = null;
|
|
297
|
+
if (taskStarted) {
|
|
298
|
+
try { stopTask(context, state, "Impel remote startup failed"); } catch (stopError) { cleanupError = stopError; }
|
|
299
|
+
}
|
|
300
|
+
removeSshAlias(runId);
|
|
301
|
+
if (credential) {
|
|
302
|
+
try { state = await revokeRunCredential(config, state); } catch (revokeError) { cleanupError ||= revokeError; }
|
|
303
|
+
}
|
|
304
|
+
removeRunSecrets(runId);
|
|
305
|
+
writeRunState({
|
|
306
|
+
...state,
|
|
307
|
+
status: "failed",
|
|
308
|
+
failedAt: new Date().toISOString(),
|
|
309
|
+
error: redactSecretText(error?.message || error),
|
|
310
|
+
...(cleanupError ? { cleanupError: redactSecretText(cleanupError?.message || cleanupError) } : {}),
|
|
311
|
+
});
|
|
312
|
+
throw error;
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
function printRun(state, json = false) {
|
|
317
|
+
if (json) {
|
|
318
|
+
console.log(JSON.stringify(state, null, 2));
|
|
319
|
+
return;
|
|
320
|
+
}
|
|
321
|
+
console.log(`Remote run ${state.runId} is ${state.status}.`);
|
|
322
|
+
console.log(`SSH alias: ${state.alias}`);
|
|
323
|
+
console.log(`Project: ${state.repository.remoteProjectPath}`);
|
|
324
|
+
console.log(`Expires: ${state.expiresAt}`);
|
|
325
|
+
if (state.status === "running") {
|
|
326
|
+
console.log("");
|
|
327
|
+
console.log(`Terminal: impel remote attach ${state.runId} --provider ${state.provider}`);
|
|
328
|
+
const appLabel = state.provider === "codex" ? "Codex app" : "Claude app";
|
|
329
|
+
console.log(`${appLabel}: impel remote attach ${state.runId} --provider ${state.provider} --desktop`);
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
async function cmdUp(argv, { dispatch = false } = {}) {
|
|
334
|
+
const spec = lifecycleSpec();
|
|
335
|
+
const { flags, positionals } = parseFlags(argv, spec);
|
|
336
|
+
rejectFlags(flags, new Set([
|
|
337
|
+
"env", "help", "install", "json", "profile", "provider", "region", "session", "setup", "stack", "timeout", "ttl", "fork",
|
|
338
|
+
]), dispatch ? "dispatch" : "up");
|
|
339
|
+
rejectMissingFlagValues(flags, spec, dispatch ? "dispatch" : "up");
|
|
340
|
+
if (flags.help) { console.log(HELP); return null; }
|
|
341
|
+
if (positionals.length > 1) fail(`impel remote ${dispatch ? "dispatch" : "up"}: expected at most one repository path`);
|
|
342
|
+
if (!dispatch && (flags.session || flags.fork)) fail("impel remote up: --session and --fork are only supported by `impel remote dispatch`");
|
|
343
|
+
if (dispatch && !flags.session) fail("impel remote dispatch: --session is required");
|
|
344
|
+
const state = await createRemoteRun({ ...flags, path: positionals[0] });
|
|
345
|
+
printRun(state, flags.json === true);
|
|
346
|
+
return state;
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
function syncTaskStatus(state) {
|
|
350
|
+
if (!state?.aws?.taskArn || ["failed", "stopped"].includes(state.status)) return state;
|
|
351
|
+
const context = awsContext(state);
|
|
352
|
+
const remote = describeTask(context, state);
|
|
353
|
+
const status = remote.lastStatus === "STOPPED"
|
|
354
|
+
? "stopped"
|
|
355
|
+
: state.status;
|
|
356
|
+
return writeRunState({
|
|
357
|
+
...state,
|
|
358
|
+
status,
|
|
359
|
+
...(status === "stopped" && !state.stoppedAt ? { stoppedAt: new Date().toISOString() } : {}),
|
|
360
|
+
aws: { ...state.aws, ...remote },
|
|
361
|
+
});
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
async function cmdStatus(argv) {
|
|
365
|
+
const spec = { json: { type: "boolean" }, help: { type: "boolean" } };
|
|
366
|
+
const { flags, positionals } = parseFlags(argv, spec);
|
|
367
|
+
rejectFlags(flags, new Set(["help", "json"]), "status");
|
|
368
|
+
if (flags.help) { console.log(HELP); return; }
|
|
369
|
+
if (positionals.length > 1) fail("impel remote status: expected at most one run id");
|
|
370
|
+
if (!positionals[0] && flags.json) {
|
|
371
|
+
const states = listRunStates().map((state) => {
|
|
372
|
+
try { return syncTaskStatus(state); } catch (error) {
|
|
373
|
+
return { ...state, statusError: redactSecretText(error?.message || error) };
|
|
374
|
+
}
|
|
375
|
+
});
|
|
376
|
+
console.log(JSON.stringify(states, null, 2));
|
|
377
|
+
return;
|
|
378
|
+
}
|
|
379
|
+
const runId = resolveRunId(positionals[0], { includeStopped: true });
|
|
380
|
+
const state = syncTaskStatus(readRunState(runId));
|
|
381
|
+
printRun(state, flags.json === true);
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
function shellQuote(value) {
|
|
385
|
+
return `'${String(value).replaceAll("'", `'"'"'`)}'`;
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
function providerCommand(provider, { session, fork }) {
|
|
389
|
+
if (provider === "codex") {
|
|
390
|
+
if (!session) return ["codex"];
|
|
391
|
+
return ["codex", fork ? "fork" : "resume", session];
|
|
392
|
+
}
|
|
393
|
+
return [
|
|
394
|
+
"claude",
|
|
395
|
+
...(session ? ["--resume", session] : []),
|
|
396
|
+
...(fork ? ["--fork-session"] : []),
|
|
397
|
+
];
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
function openDesktop(state, provider) {
|
|
401
|
+
if (process.platform !== "darwin") {
|
|
402
|
+
fail("impel remote attach --desktop currently supports macOS; use the printed SSH alias in the desktop app on other platforms");
|
|
403
|
+
}
|
|
404
|
+
if (provider === "codex") {
|
|
405
|
+
const uri = `codex://settings/connections/ssh/add?name=${encodeURIComponent(state.alias)}`;
|
|
406
|
+
runCapture("open", [uri]);
|
|
407
|
+
console.log(`Opened Codex SSH connection setup for ${state.alias}.`);
|
|
408
|
+
console.log(`Save ${state.repository.remoteProjectPath} as the remote project, then use the chat footer's run-location picker to hand off the chat.`);
|
|
409
|
+
return;
|
|
410
|
+
}
|
|
411
|
+
runCapture("open", ["-a", "Claude"]);
|
|
412
|
+
console.log(`Opened Claude. Choose the environment menu, Add SSH connection, and select ${state.alias}.`);
|
|
413
|
+
console.log("Claude can start a native SSH session there; use `impel remote dispatch` to checkpoint and fork an existing Claude CLI session.");
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
async function cmdAttach(argv) {
|
|
417
|
+
const spec = {
|
|
418
|
+
desktop: { type: "boolean" },
|
|
419
|
+
fork: { type: "boolean" },
|
|
420
|
+
help: { type: "boolean" },
|
|
421
|
+
provider: { type: "string" },
|
|
422
|
+
session: { type: "string" },
|
|
423
|
+
};
|
|
424
|
+
const { flags, positionals } = parseFlags(argv, spec);
|
|
425
|
+
rejectFlags(flags, new Set(["desktop", "fork", "help", "provider", "session"]), "attach");
|
|
426
|
+
rejectMissingFlagValues(flags, spec, "attach");
|
|
427
|
+
if (flags.help) { console.log(HELP); return; }
|
|
428
|
+
if (positionals.length > 1) fail("impel remote attach: expected at most one run id");
|
|
429
|
+
const state = syncTaskStatus(readRunState(resolveRunId(positionals[0])));
|
|
430
|
+
if (state.status !== "running") fail(`impel remote attach: run ${state.runId} is ${state.status}, not running`);
|
|
431
|
+
const provider = providerFlag(flags.provider, state.provider);
|
|
432
|
+
const requiredScope = credentialScopes(provider)[0];
|
|
433
|
+
if (!state.credential?.scopes?.includes(requiredScope)) {
|
|
434
|
+
fail(`impel remote attach: run ${state.runId} was scoped for ${state.provider}, not ${provider}; start a ${provider} run instead`);
|
|
435
|
+
}
|
|
436
|
+
if (flags.desktop) return openDesktop(state, provider);
|
|
437
|
+
const session = flags.session || state.session?.id || null;
|
|
438
|
+
const fork = flags.fork === true || state.session?.fork === true;
|
|
439
|
+
if (fork && !session) fail("impel remote attach: --fork requires --session");
|
|
440
|
+
const command = providerCommand(provider, { session, fork });
|
|
441
|
+
const remoteCommand = `cd ${shellQuote(state.repository.remoteProjectPath)} && exec ${command.map(shellQuote).join(" ")}`;
|
|
442
|
+
const exitCode = runInteractive(process.env.IMPEL_REMOTE_SSH_BIN || "ssh", ["-t", state.alias, remoteCommand], { allowFailure: true });
|
|
443
|
+
if (exitCode !== 0) process.exitCode = exitCode;
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
async function cmdDispatch(argv) {
|
|
447
|
+
const state = await cmdUp(argv, { dispatch: true });
|
|
448
|
+
if (!state) return;
|
|
449
|
+
const { flags } = parseFlags(argv, lifecycleSpec());
|
|
450
|
+
if (flags.json) return;
|
|
451
|
+
await cmdAttach([
|
|
452
|
+
state.runId,
|
|
453
|
+
"--provider", state.provider,
|
|
454
|
+
"--session", state.session.id,
|
|
455
|
+
...(state.session.fork ? ["--fork"] : []),
|
|
456
|
+
]);
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
async function cmdDown(argv) {
|
|
460
|
+
const { flags, positionals } = parseFlags(argv, {
|
|
461
|
+
all: { type: "boolean" },
|
|
462
|
+
help: { type: "boolean" },
|
|
463
|
+
yes: { type: "boolean" },
|
|
464
|
+
});
|
|
465
|
+
rejectFlags(flags, new Set(["all", "help", "yes"]), "down");
|
|
466
|
+
if (flags.help) { console.log(HELP); return; }
|
|
467
|
+
if (flags.yes !== true) fail("impel remote down: refusing to stop the task and revoke its credential without --yes");
|
|
468
|
+
if (flags.all && positionals.length > 0) fail("impel remote down: use a run id or --all, not both");
|
|
469
|
+
if (positionals.length > 1) fail("impel remote down: expected at most one run id");
|
|
470
|
+
const states = flags.all
|
|
471
|
+
? listRunStates().filter((state) => state.status !== "stopped")
|
|
472
|
+
: [readRunState(resolveRunId(positionals[0], { includeStopped: true }))];
|
|
473
|
+
const config = loadConfig();
|
|
474
|
+
for (let state of states) {
|
|
475
|
+
const context = awsContext(state);
|
|
476
|
+
if (state.status !== "stopped" && state.aws?.taskArn) {
|
|
477
|
+
try { stopTask(context, state); } catch (error) {
|
|
478
|
+
const current = describeTask(context, state);
|
|
479
|
+
if (current.lastStatus !== "STOPPED") throw error;
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
let revokeError = null;
|
|
483
|
+
if (!state.credential?.revokedAt) {
|
|
484
|
+
if (!config?.pat) {
|
|
485
|
+
revokeError = new Error("no local Impel credential is available to revoke the remote PAT");
|
|
486
|
+
} else {
|
|
487
|
+
try { state = await revokeRunCredential(config, state); } catch (error) { revokeError = error; }
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
removeSshAlias(state.runId);
|
|
491
|
+
removeRunSecrets(state.runId);
|
|
492
|
+
state = writeRunState({
|
|
493
|
+
...state,
|
|
494
|
+
status: "stopped",
|
|
495
|
+
stoppedAt: state.stoppedAt || new Date().toISOString(),
|
|
496
|
+
...(revokeError ? {
|
|
497
|
+
credential: {
|
|
498
|
+
...state.credential,
|
|
499
|
+
revokeError: redactSecretText(revokeError?.message || revokeError),
|
|
500
|
+
},
|
|
501
|
+
} : {}),
|
|
502
|
+
});
|
|
503
|
+
if (revokeError) {
|
|
504
|
+
console.warn(`Stopped remote run ${state.runId} and removed its SSH alias, but credential revocation must be retried: ${redactSecretText(revokeError?.message || revokeError)}`);
|
|
505
|
+
process.exitCode = 1;
|
|
506
|
+
} else {
|
|
507
|
+
console.log(`Stopped remote run ${state.runId}; revoked its credential and removed its SSH alias.`);
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
function cmdProxy(argv) {
|
|
513
|
+
const spec = {
|
|
514
|
+
help: { type: "boolean" },
|
|
515
|
+
port: { type: "string" },
|
|
516
|
+
};
|
|
517
|
+
const { flags, positionals } = parseFlags(argv, spec);
|
|
518
|
+
rejectFlags(flags, new Set(["help", "port"]), "proxy");
|
|
519
|
+
rejectMissingFlagValues(flags, spec, "proxy");
|
|
520
|
+
if (flags.help) { console.log(HELP); return; }
|
|
521
|
+
if (positionals.length !== 1) fail("impel remote proxy: a run id is required");
|
|
522
|
+
const port = integerFlag(flags.port, "port", 22, 1, 65535);
|
|
523
|
+
if (port !== 22) fail("impel remote proxy: the runner only exposes SSH port 22");
|
|
524
|
+
const state = readRunState(positionals[0]);
|
|
525
|
+
if (state.status === "stopped" || state.status === "failed") {
|
|
526
|
+
fail(`impel remote proxy: run ${state.runId} is ${state.status}`);
|
|
527
|
+
}
|
|
528
|
+
const exitCode = startSshProxy(awsContext(state), state, port);
|
|
529
|
+
if (exitCode !== 0) process.exitCode = exitCode;
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
export async function cmdRemote(argv) {
|
|
533
|
+
const [action, ...rest] = argv;
|
|
534
|
+
try {
|
|
535
|
+
switch (action) {
|
|
536
|
+
case undefined:
|
|
537
|
+
case "help":
|
|
538
|
+
case "--help":
|
|
539
|
+
case "-h":
|
|
540
|
+
console.log(HELP);
|
|
541
|
+
return;
|
|
542
|
+
case "up": return await cmdUp(rest);
|
|
543
|
+
case "status": return await cmdStatus(rest);
|
|
544
|
+
case "attach": return await cmdAttach(rest);
|
|
545
|
+
case "dispatch": return await cmdDispatch(rest);
|
|
546
|
+
case "down": return await cmdDown(rest);
|
|
547
|
+
case "proxy": return cmdProxy(rest);
|
|
548
|
+
default: fail(`impel remote: unknown subcommand ${JSON.stringify(action)}`);
|
|
549
|
+
}
|
|
550
|
+
} catch (error) {
|
|
551
|
+
if (error instanceof RemoteCommandError) {
|
|
552
|
+
console.error(error.message);
|
|
553
|
+
process.exitCode = 1;
|
|
554
|
+
return;
|
|
555
|
+
}
|
|
556
|
+
console.error(`impel remote: ${redactSecretText(error?.message || error)}`);
|
|
557
|
+
process.exitCode = 1;
|
|
558
|
+
}
|
|
559
|
+
}
|