tinker-agent 2.12.0 → 3.0.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/CHANGELOG.md +66 -1
- package/README.md +25 -4
- package/package.json +2 -1
- package/src/agent/runtime-hosted-session.ts +505 -81
- package/src/agent/runtime-session-contracts.ts +9 -0
- package/src/agent/runtime-session.ts +29 -7
- package/src/cli/command-line.ts +159 -11
- package/src/cli/connect-runner.tsx +63 -0
- package/src/cli/connect-workspace.ts +33 -0
- package/src/cli/default-service-config.ts +163 -0
- package/src/cli/default-tui-runner.ts +23 -0
- package/src/cli/interactive-runtime.ts +87 -0
- package/src/cli/local-service-start.ts +131 -0
- package/src/cli/local-tui-client.ts +234 -0
- package/src/cli/main.ts +46 -3
- package/src/cli/public-cli-contract.ts +65 -2
- package/src/cli/public-config-contract.ts +1 -1
- package/src/cli/runner-dependencies.ts +35 -35
- package/src/cli/serve-runner.ts +155 -21
- package/src/cli/serve-runtime.ts +47 -46
- package/src/cli/service-control.ts +27 -0
- package/src/cli/service-supervisor.ts +170 -0
- package/src/cli/service-upgrade.ts +106 -0
- package/src/cli/tui-runner.tsx +9 -261
- package/src/cli/update-runner.ts +4 -0
- package/src/client/local-workspace-client.ts +238 -0
- package/src/client/model-catalog.ts +15 -0
- package/src/client/remote-images.ts +80 -0
- package/src/client/remote-prompt-history.ts +22 -0
- package/src/client/remote-tasks.ts +231 -0
- package/src/client/remote-tui-view.ts +69 -0
- package/src/client/remote-workspace-client.ts +528 -0
- package/src/client/session-client.ts +109 -0
- package/src/image/image-asset-store.ts +17 -0
- package/src/remote/client.ts +27 -5
- package/src/remote/config.ts +19 -4
- package/src/remote/failures.ts +68 -0
- package/src/remote/http-server.ts +120 -3
- package/src/remote/local-service-discovery.ts +361 -0
- package/src/remote/prompt-history.ts +36 -0
- package/src/remote/protocol.ts +93 -2
- package/src/remote/resident-policy.ts +96 -0
- package/src/remote/service-store.ts +28 -6
- package/src/remote/service.ts +479 -60
- package/src/remote/session-operations.ts +66 -0
- package/src/remote/session-reads.ts +126 -0
- package/src/remote/sync-hub.ts +2 -0
- package/src/remote/tui-protocol.ts +32 -0
- package/src/remote/workspace-resolution.ts +82 -0
- package/src/session/resume-projection.ts +19 -2
- package/src/session/session-lock.ts +32 -0
- package/src/session/session-store-contracts.ts +2 -0
- package/src/session/session-store.ts +7 -4
- package/src/tools/bash.ts +9 -1
- package/src/tools/bounded-output-preview.ts +2 -2
- package/src/tui/app.tsx +67 -26
- package/src/tui/components/model-picker.tsx +5 -5
- package/src/tui/components/prompt-input.tsx +16 -1
- package/src/tui/components/resume-session-picker.tsx +6 -2
- package/src/tui/tui-projection-store.ts +25 -0
- package/src/tui/tui-session-controller.ts +12 -282
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { SessionLease } from "../session/session-lock";
|
|
1
2
|
import type {
|
|
2
3
|
ProviderRetryDecision,
|
|
3
4
|
ProviderRetrySnapshot,
|
|
@@ -100,6 +101,12 @@ export type RuntimeSession = {
|
|
|
100
101
|
signal: AbortSignal,
|
|
101
102
|
prospectiveMessageImageCount: number,
|
|
102
103
|
): Promise<ImportedImageAsset>;
|
|
104
|
+
importImageBytes(
|
|
105
|
+
bytes: Buffer,
|
|
106
|
+
originalName: string,
|
|
107
|
+
signal: AbortSignal,
|
|
108
|
+
prospectiveMessageImageCount: number,
|
|
109
|
+
): Promise<ImportedImageAsset>;
|
|
103
110
|
verifyImageAssets(
|
|
104
111
|
assets: readonly ImageAssetRef[],
|
|
105
112
|
signal: AbortSignal,
|
|
@@ -127,6 +134,7 @@ export type RuntimeSession = {
|
|
|
127
134
|
askUser(): AskUserSnapshot;
|
|
128
135
|
subscribeAskUser(listener: () => void): () => void;
|
|
129
136
|
resolveAskUser(response: AskUserResolution): Promise<void>;
|
|
137
|
+
retainSessionLease(): SessionLease;
|
|
130
138
|
dispose(reason: SessionDisposeReason): Promise<void>;
|
|
131
139
|
};
|
|
132
140
|
|
|
@@ -208,6 +216,7 @@ export type SkillsUpdateSummary = {
|
|
|
208
216
|
};
|
|
209
217
|
|
|
210
218
|
export type CommonRuntimeSessionInput = {
|
|
219
|
+
sessionLease?: SessionLease;
|
|
211
220
|
workspaceRoot: string;
|
|
212
221
|
homeRoot?: string;
|
|
213
222
|
modelName: string;
|
|
@@ -170,6 +170,7 @@ const defaultDependencies: RuntimeSessionFactoryDependencies = {
|
|
|
170
170
|
...(input.homeRoot === undefined ? {} : { homeRoot: input.homeRoot }),
|
|
171
171
|
})
|
|
172
172
|
: SessionStore.openExisting({
|
|
173
|
+
lease: input.sessionLease,
|
|
173
174
|
workspaceRoot: input.workspaceRoot,
|
|
174
175
|
sessionId: input.selection.sessionId,
|
|
175
176
|
...(input.homeRoot === undefined ? {} : { homeRoot: input.homeRoot }),
|
|
@@ -774,6 +775,29 @@ class DefaultRuntimeSession implements RuntimeSession {
|
|
|
774
775
|
signal: AbortSignal,
|
|
775
776
|
prospectiveMessageImageCount: number,
|
|
776
777
|
): Promise<ImportedImageAsset> {
|
|
778
|
+
const assertImageAllowed = this.imageAdmissionCheck(prospectiveMessageImageCount);
|
|
779
|
+
if (this.supportsImageInput()) assertImageAllowed();
|
|
780
|
+
return this.assetStore.importWorkspaceFile(sourcePath, {
|
|
781
|
+
signal,
|
|
782
|
+
accept: assertImageAllowed,
|
|
783
|
+
});
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
async importImageBytes(
|
|
787
|
+
bytes: Buffer,
|
|
788
|
+
originalName: string,
|
|
789
|
+
signal: AbortSignal,
|
|
790
|
+
prospectiveMessageImageCount: number,
|
|
791
|
+
): Promise<ImportedImageAsset> {
|
|
792
|
+
const assertImageAllowed = this.imageAdmissionCheck(prospectiveMessageImageCount);
|
|
793
|
+
if (this.supportsImageInput()) assertImageAllowed();
|
|
794
|
+
return this.assetStore.importBytes(bytes, originalName, {
|
|
795
|
+
signal,
|
|
796
|
+
accept: assertImageAllowed,
|
|
797
|
+
});
|
|
798
|
+
}
|
|
799
|
+
|
|
800
|
+
private imageAdmissionCheck(prospectiveMessageImageCount: number): () => void {
|
|
777
801
|
if (this.state !== "ready") {
|
|
778
802
|
throw new Error(`Cannot import an image while RuntimeSession is ${this.state}.`);
|
|
779
803
|
}
|
|
@@ -806,13 +830,7 @@ class DefaultRuntimeSession implements RuntimeSession {
|
|
|
806
830
|
}
|
|
807
831
|
};
|
|
808
832
|
|
|
809
|
-
|
|
810
|
-
assertImageAllowed();
|
|
811
|
-
}
|
|
812
|
-
return this.assetStore.importWorkspaceFile(sourcePath, {
|
|
813
|
-
signal,
|
|
814
|
-
accept: assertImageAllowed,
|
|
815
|
-
});
|
|
833
|
+
return assertImageAllowed;
|
|
816
834
|
}
|
|
817
835
|
|
|
818
836
|
async verifyImageAssets(
|
|
@@ -1056,6 +1074,10 @@ class DefaultRuntimeSession implements RuntimeSession {
|
|
|
1056
1074
|
return completion;
|
|
1057
1075
|
}
|
|
1058
1076
|
|
|
1077
|
+
retainSessionLease() {
|
|
1078
|
+
return this.store.retainLease();
|
|
1079
|
+
}
|
|
1080
|
+
|
|
1059
1081
|
dispose(reason: SessionDisposeReason): Promise<void> {
|
|
1060
1082
|
this.disposePromise ??= this.performDispose(reason);
|
|
1061
1083
|
return this.disposePromise;
|
package/src/cli/command-line.ts
CHANGED
|
@@ -4,10 +4,27 @@ import type { PromptSource } from "./prompt-source";
|
|
|
4
4
|
import { CliUsageError, type CliCommandScope } from "./output";
|
|
5
5
|
|
|
6
6
|
export type CliCommand =
|
|
7
|
-
| { readonly type: "tui"; readonly profileName?: string }
|
|
7
|
+
| { readonly type: "tui"; readonly profileName?: string; readonly local?: boolean }
|
|
8
8
|
| { readonly type: "update" }
|
|
9
|
-
| {
|
|
10
|
-
|
|
9
|
+
| {
|
|
10
|
+
readonly type: "serve";
|
|
11
|
+
readonly configPath?: string;
|
|
12
|
+
readonly background?: boolean;
|
|
13
|
+
readonly install?: boolean;
|
|
14
|
+
readonly uninstall?: boolean;
|
|
15
|
+
readonly stop?: boolean;
|
|
16
|
+
readonly restart?: boolean;
|
|
17
|
+
readonly force?: boolean;
|
|
18
|
+
readonly status?: boolean;
|
|
19
|
+
}
|
|
20
|
+
| {
|
|
21
|
+
readonly type: "connect";
|
|
22
|
+
readonly configPath: string;
|
|
23
|
+
readonly tui?: boolean;
|
|
24
|
+
readonly workspaceId?: string;
|
|
25
|
+
readonly sessionId?: string;
|
|
26
|
+
readonly serviceConfigPath?: string;
|
|
27
|
+
}
|
|
11
28
|
| {
|
|
12
29
|
readonly type: "run";
|
|
13
30
|
readonly profileName?: string;
|
|
@@ -45,6 +62,7 @@ export async function parseCommandLine(
|
|
|
45
62
|
.helpOption(contract.helpFlags)
|
|
46
63
|
.version(packageVersion, contract.versionFlags)
|
|
47
64
|
.option(contract.tui.profileOption.flags, contract.tui.profileOption.description)
|
|
65
|
+
.option(contract.tui.localOption.flags, contract.tui.localOption.description)
|
|
48
66
|
.helpCommand(contract.helpCommand.command, contract.helpCommand.description)
|
|
49
67
|
.showHelpAfterError('Run "tinker --help" for usage.')
|
|
50
68
|
.showSuggestionAfterError(false)
|
|
@@ -61,9 +79,10 @@ export async function parseCommandLine(
|
|
|
61
79
|
});
|
|
62
80
|
|
|
63
81
|
program.action(() => {
|
|
64
|
-
const { profile } = program.opts<{ profile?: string }>();
|
|
82
|
+
const { profile, local } = program.opts<{ profile?: string; local?: boolean }>();
|
|
65
83
|
selectedCommand = Object.freeze({
|
|
66
84
|
type: "tui",
|
|
85
|
+
...(local ? { local: true } : {}),
|
|
67
86
|
...(profile === undefined
|
|
68
87
|
? {}
|
|
69
88
|
: { profileName: validateProfile(profile, "root") }),
|
|
@@ -134,17 +153,140 @@ export async function parseCommandLine(
|
|
|
134
153
|
|
|
135
154
|
for (const type of ["serve", "connect"] as const) {
|
|
136
155
|
const command = contract[type];
|
|
137
|
-
program
|
|
156
|
+
const subcommand = program
|
|
138
157
|
.command(command.command)
|
|
139
158
|
.description(command.description)
|
|
140
|
-
.requiredOption(command.configOption.flags, command.configOption.description)
|
|
141
159
|
.allowExcessArguments(false)
|
|
142
|
-
.exitOverride()
|
|
143
|
-
|
|
144
|
-
|
|
160
|
+
.exitOverride();
|
|
161
|
+
if (type === "serve") {
|
|
162
|
+
subcommand.option(command.configOption.flags, command.configOption.description);
|
|
163
|
+
subcommand.option(
|
|
164
|
+
contract.serve.backgroundOption.flags,
|
|
165
|
+
contract.serve.backgroundOption.description,
|
|
166
|
+
);
|
|
167
|
+
subcommand.option(
|
|
168
|
+
contract.serve.statusOption.flags,
|
|
169
|
+
contract.serve.statusOption.description,
|
|
170
|
+
);
|
|
171
|
+
for (const option of contract.serve.residentOptions)
|
|
172
|
+
subcommand.option(option.flags, option.description);
|
|
173
|
+
} else
|
|
174
|
+
subcommand.requiredOption(
|
|
175
|
+
command.configOption.flags,
|
|
176
|
+
command.configOption.description,
|
|
177
|
+
);
|
|
178
|
+
if (type === "connect") {
|
|
179
|
+
for (const option of [
|
|
180
|
+
contract.connect.tuiOption,
|
|
181
|
+
contract.connect.workspaceOption,
|
|
182
|
+
contract.connect.sessionOption,
|
|
183
|
+
contract.connect.serviceConfigOption,
|
|
184
|
+
])
|
|
185
|
+
subcommand.option(option.flags, option.description);
|
|
186
|
+
}
|
|
187
|
+
subcommand.action(
|
|
188
|
+
(options: {
|
|
189
|
+
config?: string;
|
|
190
|
+
background?: boolean;
|
|
191
|
+
install?: boolean;
|
|
192
|
+
uninstall?: boolean;
|
|
193
|
+
stop?: boolean;
|
|
194
|
+
restart?: boolean;
|
|
195
|
+
force?: boolean;
|
|
196
|
+
status?: boolean;
|
|
197
|
+
tui?: boolean;
|
|
198
|
+
workspace?: string;
|
|
199
|
+
session?: string;
|
|
200
|
+
serviceConfig?: string;
|
|
201
|
+
}) => {
|
|
202
|
+
if (options.config !== undefined && !options.config.trim())
|
|
145
203
|
throw new CliUsageError("--config requires a non-empty path.", type);
|
|
146
|
-
|
|
147
|
-
|
|
204
|
+
if (type === "serve") {
|
|
205
|
+
if (
|
|
206
|
+
[
|
|
207
|
+
options.background,
|
|
208
|
+
options.status,
|
|
209
|
+
options.install,
|
|
210
|
+
options.uninstall,
|
|
211
|
+
options.stop,
|
|
212
|
+
options.restart,
|
|
213
|
+
].filter(Boolean).length > 1
|
|
214
|
+
)
|
|
215
|
+
throw new CliUsageError(
|
|
216
|
+
"Service management modes are mutually exclusive.",
|
|
217
|
+
type,
|
|
218
|
+
);
|
|
219
|
+
if (
|
|
220
|
+
options.force &&
|
|
221
|
+
![options.install, options.uninstall, options.stop, options.restart].some(
|
|
222
|
+
Boolean,
|
|
223
|
+
)
|
|
224
|
+
)
|
|
225
|
+
throw new CliUsageError(
|
|
226
|
+
"--force requires --install, --uninstall, --stop or --restart.",
|
|
227
|
+
type,
|
|
228
|
+
);
|
|
229
|
+
selectedCommand = Object.freeze({
|
|
230
|
+
...(options.install ? { install: true } : {}),
|
|
231
|
+
...(options.uninstall ? { uninstall: true } : {}),
|
|
232
|
+
...(options.stop ? { stop: true } : {}),
|
|
233
|
+
...(options.restart ? { restart: true } : {}),
|
|
234
|
+
...(options.force ? { force: true } : {}),
|
|
235
|
+
type,
|
|
236
|
+
...(options.config === undefined ? {} : { configPath: options.config }),
|
|
237
|
+
...(options.background ? { background: true } : {}),
|
|
238
|
+
...(options.status ? { status: true } : {}),
|
|
239
|
+
});
|
|
240
|
+
return;
|
|
241
|
+
}
|
|
242
|
+
if (options.config === undefined)
|
|
243
|
+
throw new CliUsageError("--config is required.", type);
|
|
244
|
+
if (
|
|
245
|
+
options.tui ||
|
|
246
|
+
options.workspace !== undefined ||
|
|
247
|
+
options.session !== undefined ||
|
|
248
|
+
options.serviceConfig !== undefined
|
|
249
|
+
) {
|
|
250
|
+
if (
|
|
251
|
+
!options.tui ||
|
|
252
|
+
(options.workspace !== undefined && !options.workspace.trim())
|
|
253
|
+
)
|
|
254
|
+
throw new CliUsageError(
|
|
255
|
+
"--workspace, --session and --service-config require --tui; workspace ID must be non-empty.",
|
|
256
|
+
type,
|
|
257
|
+
);
|
|
258
|
+
if (options.serviceConfig !== undefined && !options.serviceConfig.trim())
|
|
259
|
+
throw new CliUsageError(
|
|
260
|
+
"--service-config requires a non-empty path.",
|
|
261
|
+
type,
|
|
262
|
+
);
|
|
263
|
+
if (
|
|
264
|
+
options.workspace !== undefined &&
|
|
265
|
+
!/^[a-zA-Z0-9][a-zA-Z0-9_-]{0,100}$/.test(options.workspace)
|
|
266
|
+
)
|
|
267
|
+
throw new CliUsageError("Invalid workspace ID.", type);
|
|
268
|
+
if (
|
|
269
|
+
options.session !== undefined &&
|
|
270
|
+
!/^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(
|
|
271
|
+
options.session,
|
|
272
|
+
)
|
|
273
|
+
)
|
|
274
|
+
throw new CliUsageError("Invalid session ID.", type);
|
|
275
|
+
selectedCommand = Object.freeze({
|
|
276
|
+
type,
|
|
277
|
+
configPath: options.config,
|
|
278
|
+
tui: true,
|
|
279
|
+
...(options.workspace === undefined
|
|
280
|
+
? {}
|
|
281
|
+
: { workspaceId: options.workspace }),
|
|
282
|
+
...(options.session ? { sessionId: options.session } : {}),
|
|
283
|
+
...(options.serviceConfig === undefined
|
|
284
|
+
? {}
|
|
285
|
+
: { serviceConfigPath: options.serviceConfig }),
|
|
286
|
+
});
|
|
287
|
+
} else selectedCommand = Object.freeze({ type, configPath: options.config });
|
|
288
|
+
},
|
|
289
|
+
);
|
|
148
290
|
}
|
|
149
291
|
|
|
150
292
|
program
|
|
@@ -176,6 +318,11 @@ export async function parseCommandLine(
|
|
|
176
318
|
if (selectedCommand === undefined) {
|
|
177
319
|
throw new Error("Commander completed without selecting a command.");
|
|
178
320
|
}
|
|
321
|
+
if (selectedCommand.type !== "tui" && program.opts<{ local?: boolean }>().local)
|
|
322
|
+
throw new CliUsageError(
|
|
323
|
+
"--local only applies to the default TUI.",
|
|
324
|
+
selectedCommand.type,
|
|
325
|
+
);
|
|
179
326
|
const topLevelProfile = program.opts<{ profile?: string }>().profile;
|
|
180
327
|
if (selectedCommand.type === "run" && topLevelProfile !== undefined) {
|
|
181
328
|
throw new CliUsageError(
|
|
@@ -232,6 +379,7 @@ function preflightArgv(args: readonly string[]): CliCommandScope {
|
|
|
232
379
|
index += profile.consumedNext ? 1 : 0;
|
|
233
380
|
continue;
|
|
234
381
|
}
|
|
382
|
+
if (token === "--local") continue;
|
|
235
383
|
if (token.startsWith("-")) {
|
|
236
384
|
rootBlocked = true;
|
|
237
385
|
continue;
|
|
@@ -1,15 +1,27 @@
|
|
|
1
|
+
import { App } from "../tui/app";
|
|
2
|
+
import { prepareShikiHighlighter } from "../tui/shiki-highlighter";
|
|
3
|
+
import { clipboardWriterForEnvironment } from "../tui/clipboard";
|
|
4
|
+
import { createRemoteTuiClient } from "../client/remote-workspace-client";
|
|
1
5
|
import { render } from "ink";
|
|
2
6
|
import { RemoteClient, loadRemoteClientConfig } from "../remote/client";
|
|
3
7
|
import { RemoteApp } from "../tui/remote-app";
|
|
4
8
|
import type { CliOutputWriter } from "./output";
|
|
9
|
+
import { resolveConnectedWorkspace } from "./connect-workspace";
|
|
5
10
|
|
|
6
11
|
export async function runConnect(input: {
|
|
7
12
|
configPath: string;
|
|
8
13
|
env: NodeJS.ProcessEnv;
|
|
9
14
|
stdout: CliOutputWriter;
|
|
15
|
+
cwd?: string;
|
|
16
|
+
tui?: boolean;
|
|
17
|
+
workspaceId?: string;
|
|
18
|
+
sessionId?: string;
|
|
19
|
+
profileName?: string;
|
|
20
|
+
serviceConfigPath?: string;
|
|
10
21
|
}): Promise<number> {
|
|
11
22
|
if (!process.stdin.isTTY)
|
|
12
23
|
throw new Error("tinker connect requires an interactive terminal.");
|
|
24
|
+
if (input.tui) return runFullTui(input);
|
|
13
25
|
const client = new RemoteClient(await loadRemoteClientConfig(input.configPath));
|
|
14
26
|
let instance: ReturnType<typeof render> | undefined;
|
|
15
27
|
try {
|
|
@@ -24,3 +36,54 @@ export async function runConnect(input: {
|
|
|
24
36
|
process.stdin.pause();
|
|
25
37
|
}
|
|
26
38
|
}
|
|
39
|
+
|
|
40
|
+
async function runFullTui(input: {
|
|
41
|
+
configPath: string;
|
|
42
|
+
env: NodeJS.ProcessEnv;
|
|
43
|
+
cwd?: string;
|
|
44
|
+
workspaceId?: string;
|
|
45
|
+
sessionId?: string;
|
|
46
|
+
profileName?: string;
|
|
47
|
+
serviceConfigPath?: string;
|
|
48
|
+
}): Promise<number> {
|
|
49
|
+
const config = await loadRemoteClientConfig(input.configPath);
|
|
50
|
+
const workspaceId = await resolveConnectedWorkspace({
|
|
51
|
+
...input,
|
|
52
|
+
config,
|
|
53
|
+
cwd: input.cwd ?? process.cwd(),
|
|
54
|
+
});
|
|
55
|
+
const connection = await createRemoteTuiClient(
|
|
56
|
+
config,
|
|
57
|
+
workspaceId,
|
|
58
|
+
input.sessionId,
|
|
59
|
+
input.profileName,
|
|
60
|
+
);
|
|
61
|
+
let instance: ReturnType<typeof render> | undefined;
|
|
62
|
+
try {
|
|
63
|
+
const [history, projectSlashCommands] = await Promise.all([
|
|
64
|
+
connection.client.loadHistory(),
|
|
65
|
+
connection.client.projectCommands(),
|
|
66
|
+
prepareShikiHighlighter(),
|
|
67
|
+
]);
|
|
68
|
+
instance = render(
|
|
69
|
+
<App
|
|
70
|
+
sessionController={connection.client}
|
|
71
|
+
history={history}
|
|
72
|
+
projectSlashCommands={projectSlashCommands}
|
|
73
|
+
readGitBranch={connection.client.readGitBranch}
|
|
74
|
+
fileLister={connection.client.listFiles}
|
|
75
|
+
readViewFile={connection.client.readFile}
|
|
76
|
+
writeClipboard={clipboardWriterForEnvironment(input.env)}
|
|
77
|
+
listStoredMemories={connection.client.listStoredMemories}
|
|
78
|
+
/>,
|
|
79
|
+
{ incrementalRendering: true },
|
|
80
|
+
);
|
|
81
|
+
await instance.waitUntilExit();
|
|
82
|
+
return 0;
|
|
83
|
+
} finally {
|
|
84
|
+
instance?.unmount();
|
|
85
|
+
await connection.close({ type: "client_exit" });
|
|
86
|
+
if (process.stdin.isTTY) process.stdin.setRawMode(false);
|
|
87
|
+
process.stdin.pause();
|
|
88
|
+
}
|
|
89
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { RemoteClient, type RemoteClientConfig } from "../remote/client";
|
|
2
|
+
import {
|
|
3
|
+
loadLocalServiceTarget,
|
|
4
|
+
registerLocalWorkspace,
|
|
5
|
+
} from "../remote/local-service-discovery";
|
|
6
|
+
import { ensureLocalService } from "./local-service-start";
|
|
7
|
+
|
|
8
|
+
export async function resolveConnectedWorkspace(input: {
|
|
9
|
+
config: RemoteClientConfig;
|
|
10
|
+
cwd: string;
|
|
11
|
+
env: NodeJS.ProcessEnv;
|
|
12
|
+
workspaceId?: string;
|
|
13
|
+
serviceConfigPath?: string;
|
|
14
|
+
}): Promise<string> {
|
|
15
|
+
const probe = new RemoteClient(input.config, false);
|
|
16
|
+
try {
|
|
17
|
+
if (input.serviceConfigPath) {
|
|
18
|
+
const target = await loadLocalServiceTarget(input.serviceConfigPath, input.env);
|
|
19
|
+
const local = await ensureLocalService(target, input.env);
|
|
20
|
+
const remote = await probe.request<{ instanceId: string }>("/v1/service");
|
|
21
|
+
if (remote.instanceId !== local.instanceId)
|
|
22
|
+
throw new Error(
|
|
23
|
+
"Client pairing points to a different service. Use the client configuration for this local service before registering a workspace.",
|
|
24
|
+
);
|
|
25
|
+
if (!input.workspaceId)
|
|
26
|
+
return (await registerLocalWorkspace(target, input.cwd)).id;
|
|
27
|
+
}
|
|
28
|
+
if (input.workspaceId) return input.workspaceId;
|
|
29
|
+
return (await probe.resolveWorkspace(input.cwd)).id;
|
|
30
|
+
} finally {
|
|
31
|
+
await probe.close();
|
|
32
|
+
}
|
|
33
|
+
}
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
import { createHash, randomBytes, randomUUID } from "node:crypto";
|
|
2
|
+
import { chmod, mkdir, readFile, rename, rm, stat, writeFile } from "node:fs/promises";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import {
|
|
5
|
+
defaultServiceConfigPath,
|
|
6
|
+
loadLocalServiceTarget,
|
|
7
|
+
} from "../remote/local-service-discovery";
|
|
8
|
+
import { loadRemoteClientConfig } from "../remote/client";
|
|
9
|
+
import { authenticateDevice } from "../remote/config";
|
|
10
|
+
import { ensureLocalService } from "./local-service-start";
|
|
11
|
+
|
|
12
|
+
/** Publish an entire credential/config bundle atomically; competing first launches reuse the winner. */
|
|
13
|
+
export async function prepareDefaultServiceConfig(
|
|
14
|
+
env: NodeJS.ProcessEnv,
|
|
15
|
+
): Promise<string> {
|
|
16
|
+
if (process.platform === "win32")
|
|
17
|
+
throw new Error(
|
|
18
|
+
"Default service startup requires macOS/Linux. Use tinker --local on Windows.",
|
|
19
|
+
);
|
|
20
|
+
const configPath = defaultServiceConfigPath(env);
|
|
21
|
+
const directory = path.dirname(configPath);
|
|
22
|
+
if (await exists(directory)) {
|
|
23
|
+
if (!(await exists(configPath)))
|
|
24
|
+
throw new Error(
|
|
25
|
+
`Service directory has no service.json: ${directory}. Complete its configuration or use tinker --local.`,
|
|
26
|
+
);
|
|
27
|
+
return configPath;
|
|
28
|
+
}
|
|
29
|
+
await mkdir(path.dirname(directory), { recursive: true, mode: 0o700 });
|
|
30
|
+
const temporary = `${directory}.setup-${randomUUID()}`;
|
|
31
|
+
await mkdir(temporary, { mode: 0o700 });
|
|
32
|
+
try {
|
|
33
|
+
const child = Bun.spawn(
|
|
34
|
+
[
|
|
35
|
+
"openssl",
|
|
36
|
+
"req",
|
|
37
|
+
"-x509",
|
|
38
|
+
"-newkey",
|
|
39
|
+
"rsa:2048",
|
|
40
|
+
"-nodes",
|
|
41
|
+
"-sha256",
|
|
42
|
+
"-days",
|
|
43
|
+
"365",
|
|
44
|
+
"-subj",
|
|
45
|
+
"/CN=Tinker local service",
|
|
46
|
+
"-addext",
|
|
47
|
+
"subjectAltName=DNS:localhost,IP:127.0.0.1",
|
|
48
|
+
"-addext",
|
|
49
|
+
"basicConstraints=critical,CA:TRUE",
|
|
50
|
+
"-keyout",
|
|
51
|
+
path.join(temporary, "server.key"),
|
|
52
|
+
"-out",
|
|
53
|
+
path.join(temporary, "server.crt"),
|
|
54
|
+
],
|
|
55
|
+
{ stdout: "ignore", stderr: "pipe" },
|
|
56
|
+
);
|
|
57
|
+
const [error, code] = await Promise.all([
|
|
58
|
+
new Response(child.stderr).text(),
|
|
59
|
+
child.exited,
|
|
60
|
+
]);
|
|
61
|
+
if (code !== 0)
|
|
62
|
+
throw new Error(`Cannot prepare local TLS certificate: ${error.trim()}`);
|
|
63
|
+
await chmod(path.join(temporary, "server.key"), 0o600);
|
|
64
|
+
await chmod(path.join(temporary, "server.crt"), 0o600);
|
|
65
|
+
const token = randomBytes(32).toString("base64url");
|
|
66
|
+
await writeFile(
|
|
67
|
+
path.join(temporary, "service.json"),
|
|
68
|
+
JSON.stringify(
|
|
69
|
+
{
|
|
70
|
+
version: 1,
|
|
71
|
+
localBootstrap: 1,
|
|
72
|
+
hostname: "127.0.0.1",
|
|
73
|
+
port: 0,
|
|
74
|
+
tls: { certFile: "./server.crt", keyFile: "./server.key" },
|
|
75
|
+
devices: [
|
|
76
|
+
{
|
|
77
|
+
id: "local-terminal",
|
|
78
|
+
name: "Local terminal",
|
|
79
|
+
tokenSha256: createHash("sha256").update(token).digest("hex"),
|
|
80
|
+
},
|
|
81
|
+
],
|
|
82
|
+
workspaces: [],
|
|
83
|
+
},
|
|
84
|
+
null,
|
|
85
|
+
2,
|
|
86
|
+
) + "\n",
|
|
87
|
+
{ mode: 0o600 },
|
|
88
|
+
);
|
|
89
|
+
await writeFile(
|
|
90
|
+
path.join(temporary, "client.json"),
|
|
91
|
+
JSON.stringify(
|
|
92
|
+
{
|
|
93
|
+
url: "https://127.0.0.1:1",
|
|
94
|
+
token,
|
|
95
|
+
caFile: "./server.crt",
|
|
96
|
+
},
|
|
97
|
+
null,
|
|
98
|
+
2,
|
|
99
|
+
) + "\n",
|
|
100
|
+
{ mode: 0o600 },
|
|
101
|
+
);
|
|
102
|
+
try {
|
|
103
|
+
await rename(temporary, directory);
|
|
104
|
+
} catch (error) {
|
|
105
|
+
if (
|
|
106
|
+
!["EEXIST", "ENOTEMPTY"].includes((error as NodeJS.ErrnoException).code ?? "")
|
|
107
|
+
)
|
|
108
|
+
throw error;
|
|
109
|
+
if (!(await exists(configPath)))
|
|
110
|
+
throw new Error(`Incomplete service configuration at ${directory}.`, {
|
|
111
|
+
cause: error,
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
return configPath;
|
|
115
|
+
} finally {
|
|
116
|
+
await rm(temporary, { recursive: true, force: true });
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export async function prepareDefaultConnection(env: NodeJS.ProcessEnv) {
|
|
121
|
+
const serviceConfigPath = await prepareDefaultServiceConfig(env);
|
|
122
|
+
const configPath = path.join(path.dirname(serviceConfigPath), "client.json");
|
|
123
|
+
// Validate credentials before starting a service; never replace an existing pairing.
|
|
124
|
+
const client = await loadRemoteClientConfig(configPath);
|
|
125
|
+
const target = await loadLocalServiceTarget(serviceConfigPath, env);
|
|
126
|
+
if (!authenticateDevice(`Bearer ${client.token}`, target.config.devices))
|
|
127
|
+
throw new Error(
|
|
128
|
+
`Local client credentials do not match ${serviceConfigPath}. Repair client.json before connecting.`,
|
|
129
|
+
);
|
|
130
|
+
const instance = await ensureLocalService(target, env);
|
|
131
|
+
const raw = JSON.parse(await readFile(serviceConfigPath, "utf8")) as {
|
|
132
|
+
localBootstrap?: unknown;
|
|
133
|
+
};
|
|
134
|
+
if (raw.localBootstrap === 1) {
|
|
135
|
+
// Auto-created local pairing follows the actual loopback port after every restart.
|
|
136
|
+
const pairing = JSON.parse(await readFile(configPath, "utf8")) as Record<
|
|
137
|
+
string,
|
|
138
|
+
unknown
|
|
139
|
+
>;
|
|
140
|
+
const temporary = `${configPath}.${randomUUID()}.tmp`;
|
|
141
|
+
try {
|
|
142
|
+
await writeFile(
|
|
143
|
+
temporary,
|
|
144
|
+
JSON.stringify({ ...pairing, url: instance.url }, null, 2) + "\n",
|
|
145
|
+
{ mode: 0o600 },
|
|
146
|
+
);
|
|
147
|
+
await rename(temporary, configPath);
|
|
148
|
+
} finally {
|
|
149
|
+
await rm(temporary, { force: true });
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
return { serviceConfigPath, configPath };
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
async function exists(filename: string): Promise<boolean> {
|
|
156
|
+
try {
|
|
157
|
+
await stat(filename);
|
|
158
|
+
return true;
|
|
159
|
+
} catch (error) {
|
|
160
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") return false;
|
|
161
|
+
throw error;
|
|
162
|
+
}
|
|
163
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { resolveUserPath } from "./public-config-contract";
|
|
2
|
+
import type { CliOutputWriter } from "./output";
|
|
3
|
+
import { prepareDefaultConnection } from "./default-service-config";
|
|
4
|
+
import { runConnect } from "./connect-runner";
|
|
5
|
+
|
|
6
|
+
export async function runDefaultTui(input: {
|
|
7
|
+
cwd: string;
|
|
8
|
+
env: NodeJS.ProcessEnv;
|
|
9
|
+
stdout: CliOutputWriter;
|
|
10
|
+
profileName?: string;
|
|
11
|
+
}): Promise<number> {
|
|
12
|
+
if (!process.stdin.isTTY)
|
|
13
|
+
throw new Error(
|
|
14
|
+
"tinker requires an interactive terminal. Use tinker run for one-shot execution.",
|
|
15
|
+
);
|
|
16
|
+
const configs = await prepareDefaultConnection(input.env);
|
|
17
|
+
return runConnect({
|
|
18
|
+
...input,
|
|
19
|
+
...configs,
|
|
20
|
+
cwd: resolveUserPath(input.cwd, input.env.TINKER_WORKSPACE?.trim() || "."),
|
|
21
|
+
tui: true,
|
|
22
|
+
});
|
|
23
|
+
}
|