@exagone313/dsh-podman 0.2.0-rc.2
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/LICENSE +21 -0
- package/README.md +41 -0
- package/README.zh.md +41 -0
- package/cordis.patch.yml +17 -0
- package/dist/approval-reasons.d.ts +129 -0
- package/dist/approval-reasons.js +253 -0
- package/dist/approval-reasons.test.d.ts +1 -0
- package/dist/approval-reasons.test.js +115 -0
- package/dist/approval.d.ts +29 -0
- package/dist/approval.js +412 -0
- package/dist/approval.test.d.ts +1 -0
- package/dist/approval.test.js +462 -0
- package/dist/card-route.d.ts +15 -0
- package/dist/card-route.js +478 -0
- package/dist/card-route.test.d.ts +1 -0
- package/dist/card-route.test.js +181 -0
- package/dist/card-test-support.d.ts +15 -0
- package/dist/card-test-support.js +160 -0
- package/dist/client/ContainerCard.d.ts +6 -0
- package/dist/client/ContainerCard.js +79 -0
- package/dist/client/card-client.d.ts +6 -0
- package/dist/client/card-client.js +47 -0
- package/dist/client/card-protocol.d.ts +101 -0
- package/dist/client/card-protocol.js +10 -0
- package/dist/client/container-card-caches.d.ts +10 -0
- package/dist/client/container-card-caches.js +31 -0
- package/dist/client/container-card-controller.d.ts +107 -0
- package/dist/client/container-card-controller.js +207 -0
- package/dist/client/container-card-create-modal.d.ts +24 -0
- package/dist/client/container-card-create-modal.js +109 -0
- package/dist/client/container-card-directory-styles.d.ts +5 -0
- package/dist/client/container-card-directory-styles.js +246 -0
- package/dist/client/container-card-directory.d.ts +12 -0
- package/dist/client/container-card-directory.js +144 -0
- package/dist/client/container-card-editors.d.ts +30 -0
- package/dist/client/container-card-editors.js +116 -0
- package/dist/client/container-card-images.d.ts +37 -0
- package/dist/client/container-card-images.js +35 -0
- package/dist/client/container-card-paths.d.ts +20 -0
- package/dist/client/container-card-paths.js +88 -0
- package/dist/client/container-card-row.d.ts +30 -0
- package/dist/client/container-card-row.js +54 -0
- package/dist/client/container-card-secrets.d.ts +21 -0
- package/dist/client/container-card-secrets.js +71 -0
- package/dist/client/container-card-shared.d.ts +55 -0
- package/dist/client/container-card-shared.js +152 -0
- package/dist/client/container-card-styles.d.ts +21 -0
- package/dist/client/container-card-styles.js +143 -0
- package/dist/client/container-card-volumes.d.ts +12 -0
- package/dist/client/container-card-volumes.js +37 -0
- package/dist/client/container-card-workspace.d.ts +31 -0
- package/dist/client/container-card-workspace.js +24 -0
- package/dist/client/directory-picker.d.ts +12 -0
- package/dist/client/directory-picker.js +30 -0
- package/dist/client/index.d.ts +5 -0
- package/dist/client/index.js +4419 -0
- package/dist/client/locales.d.ts +9 -0
- package/dist/client/locales.js +370 -0
- package/dist/client/read-only-approval.d.ts +8 -0
- package/dist/client/read-only-approval.js +63 -0
- package/dist/client/slot-contract.d.ts +12 -0
- package/dist/client/slot-contract.js +4 -0
- package/dist/client/terminal-styles.d.ts +5 -0
- package/dist/client/terminal-styles.js +31 -0
- package/dist/client/tool-views.d.ts +8 -0
- package/dist/client/tool-views.js +241 -0
- package/dist/containers.test.d.ts +1 -0
- package/dist/containers.test.js +443 -0
- package/dist/daemons.test.d.ts +1 -0
- package/dist/daemons.test.js +153 -0
- package/dist/fs-provider.d.ts +23 -0
- package/dist/fs-provider.js +184 -0
- package/dist/fs-provider.test.d.ts +1 -0
- package/dist/fs-provider.test.js +260 -0
- package/dist/generated/version.d.ts +2 -0
- package/dist/generated/version.js +6 -0
- package/dist/grpc/proto/dshctl/v1/control.proto +133 -0
- package/dist/grpc/proto/dshguest/v1/guest.proto +79 -0
- package/dist/grpc/runtime-client.d.ts +5 -0
- package/dist/grpc/runtime-client.js +43 -0
- package/dist/guest-rpc.d.ts +62 -0
- package/dist/guest-rpc.js +391 -0
- package/dist/images.test.d.ts +1 -0
- package/dist/images.test.js +79 -0
- package/dist/index.d.ts +22 -0
- package/dist/index.js +140 -0
- package/dist/misc.test.d.ts +1 -0
- package/dist/misc.test.js +141 -0
- package/dist/mount-enums.d.ts +22 -0
- package/dist/mount-enums.js +81 -0
- package/dist/mount-input.d.ts +14 -0
- package/dist/mount-input.js +64 -0
- package/dist/mounts.test.d.ts +1 -0
- package/dist/mounts.test.js +408 -0
- package/dist/output-reader.d.ts +34 -0
- package/dist/output-reader.js +87 -0
- package/dist/paths.test.d.ts +1 -0
- package/dist/paths.test.js +74 -0
- package/dist/preferences.d.ts +6 -0
- package/dist/preferences.js +27 -0
- package/dist/project-path.d.ts +6 -0
- package/dist/project-path.js +73 -0
- package/dist/project-path.test.d.ts +1 -0
- package/dist/project-path.test.js +52 -0
- package/dist/prompts.d.ts +13 -0
- package/dist/prompts.js +149 -0
- package/dist/public.d.ts +4 -0
- package/dist/public.js +63 -0
- package/dist/read-only-shell.d.ts +14 -0
- package/dist/read-only-shell.js +150 -0
- package/dist/read-only-shell.test.d.ts +1 -0
- package/dist/read-only-shell.test.js +127 -0
- package/dist/secrets.test.d.ts +1 -0
- package/dist/secrets.test.js +181 -0
- package/dist/settings-commands.test.d.ts +1 -0
- package/dist/settings-commands.test.js +549 -0
- package/dist/settings-create.test.d.ts +1 -0
- package/dist/settings-create.test.js +394 -0
- package/dist/settings-mounts.test.d.ts +1 -0
- package/dist/settings-mounts.test.js +467 -0
- package/dist/settings-paths.test.d.ts +1 -0
- package/dist/settings-paths.test.js +65 -0
- package/dist/settings-schema.d.ts +14 -0
- package/dist/settings-schema.js +14 -0
- package/dist/settings-workspaces.test.d.ts +1 -0
- package/dist/settings-workspaces.test.js +265 -0
- package/dist/spill-store.d.ts +17 -0
- package/dist/spill-store.js +54 -0
- package/dist/spill-store.test.d.ts +1 -0
- package/dist/spill-store.test.js +59 -0
- package/dist/subprocess.d.ts +9 -0
- package/dist/subprocess.js +369 -0
- package/dist/subprocess.test.d.ts +1 -0
- package/dist/subprocess.test.js +234 -0
- package/dist/test-support.d.ts +328 -0
- package/dist/test-support.js +437 -0
- package/dist/tool-defs.d.ts +26 -0
- package/dist/tool-defs.js +188 -0
- package/dist/tool-handlers.d.ts +2 -0
- package/dist/tool-handlers.js +546 -0
- package/dist/tool-params.d.ts +803 -0
- package/dist/tool-params.js +494 -0
- package/dist/tool-schemas.d.ts +2 -0
- package/dist/tool-schemas.js +5 -0
- package/dist/tool-views.d.ts +2 -0
- package/dist/tool-views.js +56 -0
- package/dist/views.test.d.ts +1 -0
- package/dist/views.test.js +23 -0
- package/dist/volumes.test.d.ts +1 -0
- package/dist/volumes.test.js +40 -0
- package/dist/workspace-binding.d.ts +40 -0
- package/dist/workspace-binding.js +275 -0
- package/dist/workspace-binding.test.d.ts +1 -0
- package/dist/workspace-binding.test.js +337 -0
- package/docs/architecture.md +173 -0
- package/docs/architecture.zh.md +129 -0
- package/docs/configuration.md +160 -0
- package/docs/configuration.zh.md +147 -0
- package/docs/development.md +142 -0
- package/docs/development.zh.md +135 -0
- package/docs/install-dsh-and-dsh-podman.md +165 -0
- package/docs/install-dsh-and-dsh-podman.zh.md +145 -0
- package/docs/usage.md +393 -0
- package/docs/usage.zh.md +315 -0
- package/package.json +119 -0
|
@@ -0,0 +1,478 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Elouan Martinet <exa@elou.world>
|
|
2
|
+
//
|
|
3
|
+
// SPDX-License-Identifier: MIT
|
|
4
|
+
// The settings card's host half: one authenticated route below the harness API
|
|
5
|
+
// path. It serves the live orchestrator snapshot and runs one command per
|
|
6
|
+
// request, so neither the snapshot nor the commands touch the persisted
|
|
7
|
+
// settings document.
|
|
8
|
+
import { CARD_PATH, } from "./client/card-protocol.js";
|
|
9
|
+
import { renderCacheCleanNotice, resolveReasonLocale } from "./approval-reasons.js";
|
|
10
|
+
import { defaultMountMode, mountKindToProto, mountModeToProto } from "./mount-enums.js";
|
|
11
|
+
import { unaryGuest } from "./guest-rpc.js";
|
|
12
|
+
import { workspaceSlug } from "./workspace-binding.js";
|
|
13
|
+
import { GIT_COMMIT, VERSION } from "./generated/version.js";
|
|
14
|
+
// The cache clean modes the settings card can request, mapped to the control
|
|
15
|
+
// plane's enum names.
|
|
16
|
+
const CACHE_CLEAN_MODES = {
|
|
17
|
+
"keep-latest": "CACHE_CLEAN_MODE_KEEP_LATEST",
|
|
18
|
+
all: "CACHE_CLEAN_MODE_ALL",
|
|
19
|
+
};
|
|
20
|
+
function cacheCleanModeToProto(mode) {
|
|
21
|
+
const proto = Object.prototype.hasOwnProperty.call(CACHE_CLEAN_MODES, mode)
|
|
22
|
+
? CACHE_CLEAN_MODES[mode]
|
|
23
|
+
: undefined;
|
|
24
|
+
if (proto === undefined)
|
|
25
|
+
throw new Error(`unknown cache clean mode: ${mode}`);
|
|
26
|
+
return proto;
|
|
27
|
+
}
|
|
28
|
+
function mountInputToProto(mount) {
|
|
29
|
+
const kind = mountKindToProto(mount.kind || undefined);
|
|
30
|
+
const mode = mountModeToProto(mount.mode || defaultMountMode(mount.kind || undefined));
|
|
31
|
+
const result = { projectName: mount.project ?? "", kind, mode };
|
|
32
|
+
if (kind !== "MOUNT_KIND_PROJECT" && mount.destination)
|
|
33
|
+
result.destination = mount.destination;
|
|
34
|
+
if (mount.volume)
|
|
35
|
+
result.volume = mount.volume;
|
|
36
|
+
if (mount.secret)
|
|
37
|
+
result.secret = mount.secret;
|
|
38
|
+
return result;
|
|
39
|
+
}
|
|
40
|
+
function orchestratorWorkspaceViews(raw) {
|
|
41
|
+
return (raw ?? []).map((workspace) => ({
|
|
42
|
+
workspaceSlug: workspace.workspaceSlug ?? "",
|
|
43
|
+
projectName: workspace.projectName ??
|
|
44
|
+
workspace.mounts?.[0]?.projectName ??
|
|
45
|
+
workspace.workspaceSlug ??
|
|
46
|
+
"",
|
|
47
|
+
containerName: workspace.containerName ?? "",
|
|
48
|
+
imageId: workspace.imageId ?? "",
|
|
49
|
+
status: workspace.status ?? "",
|
|
50
|
+
createdAt: workspace.createdAt ?? "",
|
|
51
|
+
mounts: (workspace.mounts ?? []).map((mount) => ({
|
|
52
|
+
projectName: mount.projectName ?? "",
|
|
53
|
+
mode: mount.mode ?? "",
|
|
54
|
+
})),
|
|
55
|
+
}));
|
|
56
|
+
}
|
|
57
|
+
function dshWorkspaceViews(registry, projectsRoot) {
|
|
58
|
+
const list = registry?.list?.() ?? [];
|
|
59
|
+
return list.map((workspace) => {
|
|
60
|
+
const path = String(workspace.path ?? "");
|
|
61
|
+
const projectName = path.startsWith(`${projectsRoot}/`)
|
|
62
|
+
? path.slice(projectsRoot.length + 1)
|
|
63
|
+
: path;
|
|
64
|
+
return {
|
|
65
|
+
workspaceSlug: workspaceSlug(workspace.id),
|
|
66
|
+
projectName: projectName || String(workspace.title ?? "") || String(workspace.id ?? ""),
|
|
67
|
+
containerName: "",
|
|
68
|
+
imageId: "",
|
|
69
|
+
status: "",
|
|
70
|
+
createdAt: workspace.createdAt ?? "",
|
|
71
|
+
mounts: projectName !== "" ? [{ projectName, mode: "MOUNT_MODE_READ_WRITE" }] : [],
|
|
72
|
+
};
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
function mergeWorkspaceViews(dsh, orchestrator) {
|
|
76
|
+
const bySlug = new Map(orchestrator.map((workspace) => [workspace.workspaceSlug, workspace]));
|
|
77
|
+
const seen = new Set();
|
|
78
|
+
const merged = [];
|
|
79
|
+
for (const workspace of dsh) {
|
|
80
|
+
const existing = bySlug.get(workspace.workspaceSlug);
|
|
81
|
+
merged.push(existing ? { ...workspace, ...existing } : workspace);
|
|
82
|
+
seen.add(workspace.workspaceSlug);
|
|
83
|
+
}
|
|
84
|
+
for (const workspace of orchestrator) {
|
|
85
|
+
if (!seen.has(workspace.workspaceSlug))
|
|
86
|
+
merged.push(workspace);
|
|
87
|
+
}
|
|
88
|
+
return merged;
|
|
89
|
+
}
|
|
90
|
+
// dshWorkspaceCwd returns a registered dsh workspace's host path — the cwd the
|
|
91
|
+
// resolver needs to bind a container for a card action.
|
|
92
|
+
function dshWorkspaceCwd(registry, slug) {
|
|
93
|
+
for (const workspace of registry?.list?.() ?? []) {
|
|
94
|
+
if (workspaceSlug(workspace.id) === slug) {
|
|
95
|
+
return String(workspace.path ?? "");
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
throw new Error(`unknown workspace ${JSON.stringify(slug)}`);
|
|
99
|
+
}
|
|
100
|
+
/** Build the live snapshot the card renders. */
|
|
101
|
+
export async function cardSnapshot(resolver, workspaceRegistry) {
|
|
102
|
+
const [containers, images, workspaces, volumes, secrets, caches] = await Promise.all([
|
|
103
|
+
resolver.control("listContainers", {}),
|
|
104
|
+
resolver.control("listImages", {}),
|
|
105
|
+
resolver.control("listWorkspaces", {}),
|
|
106
|
+
resolver.control("listVolumes", {}),
|
|
107
|
+
resolver.control("listSecrets", {}),
|
|
108
|
+
resolver.control("listCaches", {}),
|
|
109
|
+
]);
|
|
110
|
+
return {
|
|
111
|
+
version: VERSION,
|
|
112
|
+
commit: GIT_COMMIT,
|
|
113
|
+
projectsRoot: resolver.getConfig().projectsRoot,
|
|
114
|
+
workspaces: mergeWorkspaceViews(dshWorkspaceViews(workspaceRegistry, resolver.getConfig().projectsRoot), orchestratorWorkspaceViews(workspaces.workspaces)),
|
|
115
|
+
containers: (containers.containers ?? []).map((container) => ({
|
|
116
|
+
containerName: container.containerName ?? "",
|
|
117
|
+
workspaceSlug: container.workspaceSlug ?? "",
|
|
118
|
+
imageId: container.imageId ?? "",
|
|
119
|
+
status: container.status ?? "",
|
|
120
|
+
createdAt: container.createdAt ?? "",
|
|
121
|
+
mounts: container.mounts ?? [],
|
|
122
|
+
paths: container.paths ?? [],
|
|
123
|
+
env: container.env ?? {},
|
|
124
|
+
secretEnv: container.secretEnv ?? {},
|
|
125
|
+
})),
|
|
126
|
+
images: images.images ?? [],
|
|
127
|
+
volumes: (volumes.volumes ?? []).map((volume) => ({
|
|
128
|
+
name: volume.name ?? "",
|
|
129
|
+
})),
|
|
130
|
+
secrets: (secrets.secrets ?? []).map((secret) => ({
|
|
131
|
+
name: secret.name ?? "",
|
|
132
|
+
})),
|
|
133
|
+
caches: (caches.caches ?? []).map((cache) => ({
|
|
134
|
+
manager: cache.manager ?? "",
|
|
135
|
+
path: cache.path ?? "",
|
|
136
|
+
files: Number(cache.files ?? 0),
|
|
137
|
+
// The control plane reports int64 as a string.
|
|
138
|
+
bytes: Number(cache.bytes ?? 0),
|
|
139
|
+
})),
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
/**
|
|
143
|
+
* Run one card command, returning the notice to show (usually empty). `ctx` is
|
|
144
|
+
* the settings-injected context, read only for the session locale.
|
|
145
|
+
*/
|
|
146
|
+
export async function runCommand(ctx, resolver, workspaceRegistry, raw) {
|
|
147
|
+
const command = normalizeCommand(raw);
|
|
148
|
+
let notice = "";
|
|
149
|
+
switch (command.op) {
|
|
150
|
+
case "create": {
|
|
151
|
+
const payload = {
|
|
152
|
+
workspaceSlug: command.workspace,
|
|
153
|
+
imageId: command.image === "" ? undefined : command.image,
|
|
154
|
+
};
|
|
155
|
+
const mounts = command.mounts.map(mountInputToProto);
|
|
156
|
+
if (mounts.length > 0)
|
|
157
|
+
payload.mounts = mounts;
|
|
158
|
+
if (Object.keys(command.env).length > 0)
|
|
159
|
+
payload.env = command.env;
|
|
160
|
+
if (Object.keys(command.secretEnvMap).length > 0) {
|
|
161
|
+
payload.secretEnv = command.secretEnvMap;
|
|
162
|
+
}
|
|
163
|
+
if (command.paths.length > 0)
|
|
164
|
+
payload.paths = [...command.paths];
|
|
165
|
+
if (command.container !== "") {
|
|
166
|
+
await resolver.control("startContainer", {
|
|
167
|
+
...payload,
|
|
168
|
+
container: command.container,
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
else {
|
|
172
|
+
await resolver.control("createWorkspace", {
|
|
173
|
+
...payload,
|
|
174
|
+
projectName: command.projectName,
|
|
175
|
+
});
|
|
176
|
+
}
|
|
177
|
+
break;
|
|
178
|
+
}
|
|
179
|
+
case "container_mount_add": {
|
|
180
|
+
const m = command.mount;
|
|
181
|
+
if (m === null)
|
|
182
|
+
break;
|
|
183
|
+
const kind = mountKindToProto(m.kind || undefined);
|
|
184
|
+
const mode = mountModeToProto(m.mode || defaultMountMode(m.kind || undefined));
|
|
185
|
+
const request = {
|
|
186
|
+
workspaceSlug: command.workspace,
|
|
187
|
+
container: command.container || "default",
|
|
188
|
+
kind,
|
|
189
|
+
};
|
|
190
|
+
if (kind === "MOUNT_KIND_VOLUME") {
|
|
191
|
+
request.volume = m.volume;
|
|
192
|
+
request.destination = m.destination;
|
|
193
|
+
request.mode = mode;
|
|
194
|
+
}
|
|
195
|
+
else if (kind === "MOUNT_KIND_TMPFS") {
|
|
196
|
+
request.destination = m.destination;
|
|
197
|
+
request.mode = mode;
|
|
198
|
+
}
|
|
199
|
+
else if (kind === "MOUNT_KIND_SECRET") {
|
|
200
|
+
request.secret = m.secret;
|
|
201
|
+
request.destination = m.destination;
|
|
202
|
+
}
|
|
203
|
+
else {
|
|
204
|
+
request.project = m.project;
|
|
205
|
+
request.mode = mode;
|
|
206
|
+
}
|
|
207
|
+
await resolver.control("addContainerMount", request);
|
|
208
|
+
break;
|
|
209
|
+
}
|
|
210
|
+
case "container_mount_remove": {
|
|
211
|
+
const m = command.mount;
|
|
212
|
+
if (m === null)
|
|
213
|
+
break;
|
|
214
|
+
const kind = mountKindToProto(m.kind || undefined);
|
|
215
|
+
const request = {
|
|
216
|
+
workspaceSlug: command.workspace,
|
|
217
|
+
container: command.container || "default",
|
|
218
|
+
kind,
|
|
219
|
+
};
|
|
220
|
+
if (kind === "MOUNT_KIND_PROJECT") {
|
|
221
|
+
request.project = m.project;
|
|
222
|
+
}
|
|
223
|
+
else if (kind === "MOUNT_KIND_VOLUME") {
|
|
224
|
+
if (m.volume)
|
|
225
|
+
request.volume = m.volume;
|
|
226
|
+
}
|
|
227
|
+
else if (kind === "MOUNT_KIND_SECRET") {
|
|
228
|
+
if (m.secret)
|
|
229
|
+
request.secret = m.secret;
|
|
230
|
+
}
|
|
231
|
+
if (kind !== "MOUNT_KIND_PROJECT" && m.destination) {
|
|
232
|
+
request.destination = m.destination;
|
|
233
|
+
}
|
|
234
|
+
await resolver.control("removeContainerMount", request);
|
|
235
|
+
break;
|
|
236
|
+
}
|
|
237
|
+
case "container_mount_update": {
|
|
238
|
+
const m = command.mount;
|
|
239
|
+
if (m === null)
|
|
240
|
+
break;
|
|
241
|
+
const kind = mountKindToProto(m.kind || undefined);
|
|
242
|
+
const request = {
|
|
243
|
+
workspaceSlug: command.workspace,
|
|
244
|
+
container: command.container || "default",
|
|
245
|
+
kind,
|
|
246
|
+
mode: mountModeToProto(m.mode),
|
|
247
|
+
};
|
|
248
|
+
if (kind === "MOUNT_KIND_PROJECT") {
|
|
249
|
+
request.project = m.project;
|
|
250
|
+
}
|
|
251
|
+
else if (kind === "MOUNT_KIND_VOLUME") {
|
|
252
|
+
if (m.volume)
|
|
253
|
+
request.volume = m.volume;
|
|
254
|
+
if (m.destination)
|
|
255
|
+
request.destination = m.destination;
|
|
256
|
+
}
|
|
257
|
+
await resolver.control("updateContainerMount", request);
|
|
258
|
+
break;
|
|
259
|
+
}
|
|
260
|
+
case "container_path_set": {
|
|
261
|
+
const paths = [...command.paths];
|
|
262
|
+
await resolver.control("setContainerPaths", {
|
|
263
|
+
workspaceSlug: command.workspace,
|
|
264
|
+
container: command.container || "default",
|
|
265
|
+
paths,
|
|
266
|
+
});
|
|
267
|
+
// The container is not recreated, so push the list to the running guest
|
|
268
|
+
// agent as well; the record above is what a recreate restores.
|
|
269
|
+
const cwd = dshWorkspaceCwd(workspaceRegistry, command.workspace);
|
|
270
|
+
const container = command.container || "default";
|
|
271
|
+
const binding = container === "default"
|
|
272
|
+
? await resolver.resolve(cwd)
|
|
273
|
+
: await resolver.containerBinding(cwd, container);
|
|
274
|
+
await unaryGuest({ binding }, "setPaths", { paths });
|
|
275
|
+
break;
|
|
276
|
+
}
|
|
277
|
+
case "remove":
|
|
278
|
+
await resolver.control("removeContainer", {
|
|
279
|
+
workspaceSlug: command.workspace,
|
|
280
|
+
});
|
|
281
|
+
break;
|
|
282
|
+
case "workspace_remove":
|
|
283
|
+
await resolver.control("removeWorkspace", {
|
|
284
|
+
workspaceSlug: command.workspace,
|
|
285
|
+
});
|
|
286
|
+
break;
|
|
287
|
+
case "recreate":
|
|
288
|
+
await resolver.control("recreateContainer", {
|
|
289
|
+
workspaceSlug: command.workspace,
|
|
290
|
+
imageId: command.image === "" ? undefined : command.image,
|
|
291
|
+
...(Object.keys(command.env).length > 0 ? { env: command.env } : {}),
|
|
292
|
+
});
|
|
293
|
+
break;
|
|
294
|
+
case "volume_create":
|
|
295
|
+
await resolver.control("createVolume", { name: command.workspace });
|
|
296
|
+
break;
|
|
297
|
+
case "volume_remove":
|
|
298
|
+
await resolver.control("removeVolume", { name: command.workspace });
|
|
299
|
+
break;
|
|
300
|
+
case "image_remove":
|
|
301
|
+
await resolver.control("removeImage", { imageId: command.workspace });
|
|
302
|
+
break;
|
|
303
|
+
case "secret_create":
|
|
304
|
+
await resolver.control("createSecret", {
|
|
305
|
+
name: command.workspace,
|
|
306
|
+
...(command.length ? { length: command.length } : {}),
|
|
307
|
+
...(command.charset ? { charset: command.charset } : {}),
|
|
308
|
+
});
|
|
309
|
+
break;
|
|
310
|
+
case "secret_remove":
|
|
311
|
+
await resolver.control("removeSecret", { name: command.workspace });
|
|
312
|
+
break;
|
|
313
|
+
case "secret_set":
|
|
314
|
+
await resolver.control("writeSecretValue", {
|
|
315
|
+
name: command.workspace,
|
|
316
|
+
value: command.value,
|
|
317
|
+
});
|
|
318
|
+
break;
|
|
319
|
+
case "image_rebuild":
|
|
320
|
+
await resolver.control("rebuildImage", { imageId: command.workspace });
|
|
321
|
+
break;
|
|
322
|
+
case "image_rebuild_all":
|
|
323
|
+
await resolver.control("rebuildAllImages", {});
|
|
324
|
+
break;
|
|
325
|
+
case "container_secret_add":
|
|
326
|
+
await resolver.control("addContainerSecret", {
|
|
327
|
+
workspaceSlug: command.workspace,
|
|
328
|
+
container: command.container || "default",
|
|
329
|
+
env: command.secretEnv,
|
|
330
|
+
secret: command.secret,
|
|
331
|
+
});
|
|
332
|
+
break;
|
|
333
|
+
case "container_secret_remove":
|
|
334
|
+
await resolver.control("removeContainerSecret", {
|
|
335
|
+
workspaceSlug: command.workspace,
|
|
336
|
+
container: command.container || "default",
|
|
337
|
+
env: command.secretEnv,
|
|
338
|
+
});
|
|
339
|
+
break;
|
|
340
|
+
case "image_build":
|
|
341
|
+
await resolver.control("buildImage", {
|
|
342
|
+
imageId: command.workspace,
|
|
343
|
+
parent: command.image,
|
|
344
|
+
packages: command.packages,
|
|
345
|
+
});
|
|
346
|
+
break;
|
|
347
|
+
case "image_base_rebuild":
|
|
348
|
+
await resolver.control("rebuildBaseImage", { name: command.workspace });
|
|
349
|
+
break;
|
|
350
|
+
case "image_base_pull":
|
|
351
|
+
await resolver.control("pullBaseImage", { name: command.workspace });
|
|
352
|
+
break;
|
|
353
|
+
case "cache_clean": {
|
|
354
|
+
const result = (await resolver.control("cleanCaches", {
|
|
355
|
+
mode: cacheCleanModeToProto(command.cacheMode),
|
|
356
|
+
}));
|
|
357
|
+
notice = renderCacheCleanNotice(resolveReasonLocale(ctx?.settings), Number(result.removedFiles ?? 0));
|
|
358
|
+
break;
|
|
359
|
+
}
|
|
360
|
+
default:
|
|
361
|
+
throw new Error(`unknown command ${JSON.stringify(command.op)}`);
|
|
362
|
+
}
|
|
363
|
+
return notice;
|
|
364
|
+
}
|
|
365
|
+
function jsonResponse(status, body) {
|
|
366
|
+
return new Response(JSON.stringify(body), {
|
|
367
|
+
status,
|
|
368
|
+
headers: { "content-type": "application/json" },
|
|
369
|
+
});
|
|
370
|
+
}
|
|
371
|
+
function stringField(value, fallback = "") {
|
|
372
|
+
return typeof value === "string" ? value : fallback;
|
|
373
|
+
}
|
|
374
|
+
function stringMap(value) {
|
|
375
|
+
if (typeof value !== "object" || value === null || Array.isArray(value))
|
|
376
|
+
return {};
|
|
377
|
+
const result = {};
|
|
378
|
+
for (const [key, item] of Object.entries(value)) {
|
|
379
|
+
result[key] = String(item);
|
|
380
|
+
}
|
|
381
|
+
return result;
|
|
382
|
+
}
|
|
383
|
+
function mountField(value) {
|
|
384
|
+
if (typeof value !== "object" || value === null)
|
|
385
|
+
return null;
|
|
386
|
+
const mount = value;
|
|
387
|
+
return {
|
|
388
|
+
kind: stringField(mount.kind),
|
|
389
|
+
project: stringField(mount.project),
|
|
390
|
+
destination: stringField(mount.destination),
|
|
391
|
+
mode: stringField(mount.mode),
|
|
392
|
+
volume: stringField(mount.volume),
|
|
393
|
+
secret: stringField(mount.secret),
|
|
394
|
+
};
|
|
395
|
+
}
|
|
396
|
+
// normalizeCommand fills the defaults a caller may omit, so the command runner
|
|
397
|
+
// can rely on every field being present. The route is a public endpoint, so it
|
|
398
|
+
// must not trust the request body's shape.
|
|
399
|
+
function normalizeCommand(raw) {
|
|
400
|
+
const mounts = Array.isArray(raw.mounts) ? raw.mounts : [];
|
|
401
|
+
return {
|
|
402
|
+
op: raw.op,
|
|
403
|
+
workspace: stringField(raw.workspace),
|
|
404
|
+
projectName: stringField(raw.projectName),
|
|
405
|
+
image: stringField(raw.image),
|
|
406
|
+
mounts: mounts.map((mount) => mountField(mount) ?? {
|
|
407
|
+
kind: "",
|
|
408
|
+
project: "",
|
|
409
|
+
destination: "",
|
|
410
|
+
mode: "",
|
|
411
|
+
volume: "",
|
|
412
|
+
secret: "",
|
|
413
|
+
}),
|
|
414
|
+
paths: Array.isArray(raw.paths) ? raw.paths.map(String) : [],
|
|
415
|
+
value: stringField(raw.value),
|
|
416
|
+
env: stringMap(raw.env),
|
|
417
|
+
container: stringField(raw.container),
|
|
418
|
+
secret: stringField(raw.secret),
|
|
419
|
+
secretEnv: stringField(raw.secretEnv),
|
|
420
|
+
length: typeof raw.length === "number" ? raw.length : 0,
|
|
421
|
+
charset: stringField(raw.charset),
|
|
422
|
+
packages: Array.isArray(raw.packages) ? raw.packages.map(String) : [],
|
|
423
|
+
secretEnvMap: stringMap(raw.secretEnvMap),
|
|
424
|
+
cacheMode: stringField(raw.cacheMode),
|
|
425
|
+
mount: mountField(raw.mount),
|
|
426
|
+
};
|
|
427
|
+
}
|
|
428
|
+
async function handleCardRequest(request, ctx, resolver, workspaceRegistry) {
|
|
429
|
+
if (request.method === "GET") {
|
|
430
|
+
try {
|
|
431
|
+
return jsonResponse(200, await cardSnapshot(resolver, workspaceRegistry));
|
|
432
|
+
}
|
|
433
|
+
catch (error) {
|
|
434
|
+
return jsonResponse(500, {
|
|
435
|
+
error: error instanceof Error ? error.message : String(error),
|
|
436
|
+
});
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
if (request.method !== "POST") {
|
|
440
|
+
return new Response("method not allowed", { status: 405 });
|
|
441
|
+
}
|
|
442
|
+
let command;
|
|
443
|
+
try {
|
|
444
|
+
const body = (await request.json());
|
|
445
|
+
command = body?.command;
|
|
446
|
+
}
|
|
447
|
+
catch {
|
|
448
|
+
return jsonResponse(400, { error: "invalid JSON body" });
|
|
449
|
+
}
|
|
450
|
+
if (command === null || typeof command !== "object") {
|
|
451
|
+
return jsonResponse(400, { error: "a command is required" });
|
|
452
|
+
}
|
|
453
|
+
try {
|
|
454
|
+
const notice = await runCommand(ctx, resolver, workspaceRegistry, command);
|
|
455
|
+
const result = notice === "" ? {} : { notice };
|
|
456
|
+
return jsonResponse(200, result);
|
|
457
|
+
}
|
|
458
|
+
catch (error) {
|
|
459
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
460
|
+
const status = message.startsWith("unknown command") ? 400 : 500;
|
|
461
|
+
return jsonResponse(status, { error: message });
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
/**
|
|
465
|
+
* Register the card's authenticated fetch route. It is served below the
|
|
466
|
+
* harness API path, so the carrier applies its Host/Origin fence and browser
|
|
467
|
+
* authentication before the handler runs.
|
|
468
|
+
*/
|
|
469
|
+
export function registerCardRoute(ctx, resolver, workspaceRegistry) {
|
|
470
|
+
ctx.inject(["connection", "settings"], (connectionCtx) => {
|
|
471
|
+
connectionCtx.connection.fetch.register({
|
|
472
|
+
path: CARD_PATH,
|
|
473
|
+
methods: ["GET", "POST"],
|
|
474
|
+
requestBody: "buffered",
|
|
475
|
+
fetch: (request) => handleCardRequest(request, connectionCtx, resolver, workspaceRegistry),
|
|
476
|
+
});
|
|
477
|
+
});
|
|
478
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Elouan Martinet <exa@elou.world>
|
|
2
|
+
//
|
|
3
|
+
// SPDX-License-Identifier: MIT
|
|
4
|
+
import { test } from "node:test";
|
|
5
|
+
import { strict as assert } from "node:assert";
|
|
6
|
+
import { CARD_PATH } from "./client/card-protocol.js";
|
|
7
|
+
import { registerCardRoute } from "./card-route.js";
|
|
8
|
+
import { installContainerPreferences } from "./preferences.js";
|
|
9
|
+
function fakeResolver() {
|
|
10
|
+
const calls = [];
|
|
11
|
+
const resolver = {
|
|
12
|
+
getConfig: () => ({
|
|
13
|
+
defaultImage: "archlinux",
|
|
14
|
+
socketsRoot: "/run/dsh-podman",
|
|
15
|
+
projectsRoot: "/projects",
|
|
16
|
+
}),
|
|
17
|
+
setConfig: () => { },
|
|
18
|
+
async control(method, request) {
|
|
19
|
+
calls.push([method, request]);
|
|
20
|
+
if (method === "listContainers")
|
|
21
|
+
return { containers: [{ containerName: "c1" }] };
|
|
22
|
+
if (method === "listImages")
|
|
23
|
+
return { images: [{ imageId: "img1" }] };
|
|
24
|
+
if (method === "listWorkspaces")
|
|
25
|
+
return { workspaces: [] };
|
|
26
|
+
if (method === "listVolumes")
|
|
27
|
+
return { volumes: [{ name: "data" }] };
|
|
28
|
+
if (method === "listSecrets")
|
|
29
|
+
return { secrets: [{ name: "s1" }] };
|
|
30
|
+
if (method === "listCaches")
|
|
31
|
+
return { caches: [{ manager: "pacman", files: 1, bytes: "1024" }] };
|
|
32
|
+
if (method === "cleanCaches")
|
|
33
|
+
return { removedFiles: 2 };
|
|
34
|
+
if (method === "removeContainer")
|
|
35
|
+
return {};
|
|
36
|
+
return {};
|
|
37
|
+
},
|
|
38
|
+
};
|
|
39
|
+
return { resolver, calls };
|
|
40
|
+
}
|
|
41
|
+
function fakeRouteContext() {
|
|
42
|
+
const routes = [];
|
|
43
|
+
const ctx = {
|
|
44
|
+
inject(deps, callback) {
|
|
45
|
+
assert.deepEqual(deps, ["connection", "settings"]);
|
|
46
|
+
callback({
|
|
47
|
+
connection: {
|
|
48
|
+
fetch: {
|
|
49
|
+
register(route) {
|
|
50
|
+
// Mirror the harness's assertFetchRoute: the route must live
|
|
51
|
+
// under the API channel, or the registration is refused.
|
|
52
|
+
if (!route.path.startsWith("/api/")) {
|
|
53
|
+
throw new Error(`connection: invalid exact Fetch route ${JSON.stringify(route.path)}`);
|
|
54
|
+
}
|
|
55
|
+
routes.push(route);
|
|
56
|
+
return async () => { };
|
|
57
|
+
},
|
|
58
|
+
},
|
|
59
|
+
},
|
|
60
|
+
settings: {
|
|
61
|
+
register: () => ({ watch: () => () => { } }),
|
|
62
|
+
get: () => undefined,
|
|
63
|
+
},
|
|
64
|
+
});
|
|
65
|
+
},
|
|
66
|
+
};
|
|
67
|
+
return { ctx, routes };
|
|
68
|
+
}
|
|
69
|
+
function cardRequest(init) {
|
|
70
|
+
return new Request(`http://dsh.test${CARD_PATH}`, init);
|
|
71
|
+
}
|
|
72
|
+
test("registerCardRoute registers the card's exact fetch route", () => {
|
|
73
|
+
assert.ok(CARD_PATH.startsWith("/api/"), "the route must live under the API channel");
|
|
74
|
+
const { resolver } = fakeResolver();
|
|
75
|
+
const { ctx, routes } = fakeRouteContext();
|
|
76
|
+
registerCardRoute(ctx, resolver);
|
|
77
|
+
assert.equal(routes.length, 1);
|
|
78
|
+
assert.equal(routes[0].path, CARD_PATH);
|
|
79
|
+
assert.deepEqual(routes[0].methods, ["GET", "POST"]);
|
|
80
|
+
assert.equal(routes[0].requestBody, "buffered");
|
|
81
|
+
});
|
|
82
|
+
test("the card route serves the live snapshot on GET", async () => {
|
|
83
|
+
const { resolver, calls } = fakeResolver();
|
|
84
|
+
const { ctx, routes } = fakeRouteContext();
|
|
85
|
+
registerCardRoute(ctx, resolver);
|
|
86
|
+
const response = await routes[0].fetch(cardRequest());
|
|
87
|
+
assert.equal(response.status, 200);
|
|
88
|
+
const snapshot = (await response.json());
|
|
89
|
+
assert.equal(snapshot.projectsRoot, "/projects");
|
|
90
|
+
assert.equal(snapshot.containers.length, 1);
|
|
91
|
+
assert.equal(snapshot.images.length, 1);
|
|
92
|
+
assert.deepEqual(snapshot.volumes, [{ name: "data" }]);
|
|
93
|
+
assert.deepEqual(snapshot.secrets, [{ name: "s1" }]);
|
|
94
|
+
assert.equal(snapshot.caches[0].bytes, 1024);
|
|
95
|
+
assert.deepEqual(calls.map(([method]) => method), ["listContainers", "listImages", "listWorkspaces", "listVolumes", "listSecrets", "listCaches"]);
|
|
96
|
+
});
|
|
97
|
+
test("the card route runs one command per POST and returns its notice", async () => {
|
|
98
|
+
const { resolver, calls } = fakeResolver();
|
|
99
|
+
const { ctx, routes } = fakeRouteContext();
|
|
100
|
+
registerCardRoute(ctx, resolver);
|
|
101
|
+
const remove = await routes[0].fetch(cardRequest({
|
|
102
|
+
method: "POST",
|
|
103
|
+
body: JSON.stringify({ command: { op: "remove", workspace: "w1" } }),
|
|
104
|
+
}));
|
|
105
|
+
assert.equal(remove.status, 200);
|
|
106
|
+
assert.deepEqual(await remove.json(), {});
|
|
107
|
+
assert.deepEqual(calls.find(([method]) => method === "removeContainer")?.[1], {
|
|
108
|
+
workspaceSlug: "w1",
|
|
109
|
+
});
|
|
110
|
+
const clean = await routes[0].fetch(cardRequest({
|
|
111
|
+
method: "POST",
|
|
112
|
+
body: JSON.stringify({ command: { op: "cache_clean", cacheMode: "all" } }),
|
|
113
|
+
}));
|
|
114
|
+
assert.equal(clean.status, 200);
|
|
115
|
+
assert.deepEqual(await clean.json(), { notice: "removed 2 cached files" });
|
|
116
|
+
});
|
|
117
|
+
test("the card route rejects a missing or unknown command", async () => {
|
|
118
|
+
const { resolver } = fakeResolver();
|
|
119
|
+
const { ctx, routes } = fakeRouteContext();
|
|
120
|
+
registerCardRoute(ctx, resolver);
|
|
121
|
+
const missing = await routes[0].fetch(cardRequest({ method: "POST", body: "{}" }));
|
|
122
|
+
assert.equal(missing.status, 400);
|
|
123
|
+
const unknown = await routes[0].fetch(cardRequest({
|
|
124
|
+
method: "POST",
|
|
125
|
+
body: JSON.stringify({ command: { op: "nope" } }),
|
|
126
|
+
}));
|
|
127
|
+
assert.equal(unknown.status, 400);
|
|
128
|
+
const malformed = await routes[0].fetch(cardRequest({ method: "POST", body: "not json" }));
|
|
129
|
+
assert.equal(malformed.status, 400);
|
|
130
|
+
const wrongMethod = await routes[0].fetch(cardRequest({ method: "DELETE" }));
|
|
131
|
+
assert.equal(wrongMethod.status, 405);
|
|
132
|
+
});
|
|
133
|
+
test("the card route reports a failed command as an error", async () => {
|
|
134
|
+
const resolver = {
|
|
135
|
+
getConfig: () => ({ projectsRoot: "/projects" }),
|
|
136
|
+
setConfig: () => { },
|
|
137
|
+
async control() {
|
|
138
|
+
throw new Error("control plane unavailable");
|
|
139
|
+
},
|
|
140
|
+
};
|
|
141
|
+
const { ctx, routes } = fakeRouteContext();
|
|
142
|
+
registerCardRoute(ctx, resolver);
|
|
143
|
+
const response = await routes[0].fetch(cardRequest({
|
|
144
|
+
method: "POST",
|
|
145
|
+
body: JSON.stringify({ command: { op: "remove", workspace: "w1" } }),
|
|
146
|
+
}));
|
|
147
|
+
assert.equal(response.status, 500);
|
|
148
|
+
assert.deepEqual(await response.json(), { error: "control plane unavailable" });
|
|
149
|
+
});
|
|
150
|
+
test("installContainerPreferences seeds the base and syncs the resolver config", () => {
|
|
151
|
+
let base;
|
|
152
|
+
let watcher;
|
|
153
|
+
const ctx = {
|
|
154
|
+
inject(deps, callback) {
|
|
155
|
+
assert.deepEqual(deps, ["settings"]);
|
|
156
|
+
callback({
|
|
157
|
+
settings: {
|
|
158
|
+
register(_ns, _schema, options) {
|
|
159
|
+
base = options.base;
|
|
160
|
+
return {
|
|
161
|
+
watch(callback) {
|
|
162
|
+
watcher = callback;
|
|
163
|
+
return () => { };
|
|
164
|
+
},
|
|
165
|
+
};
|
|
166
|
+
},
|
|
167
|
+
},
|
|
168
|
+
});
|
|
169
|
+
},
|
|
170
|
+
};
|
|
171
|
+
const configs = [];
|
|
172
|
+
const resolver = {
|
|
173
|
+
getConfig: () => ({ defaultImage: "archlinux", socketsRoot: "/run/dsh-podman" }),
|
|
174
|
+
setConfig: (patch) => configs.push(patch),
|
|
175
|
+
};
|
|
176
|
+
installContainerPreferences(ctx, resolver);
|
|
177
|
+
assert.deepEqual(base, { defaultImage: "archlinux", socketsRoot: "/run/dsh-podman" });
|
|
178
|
+
assert.ok(watcher !== undefined);
|
|
179
|
+
watcher({ defaultImage: "ubuntu", socketsRoot: "/run/other" });
|
|
180
|
+
assert.deepEqual(configs, [{ defaultImage: "ubuntu", socketsRoot: "/run/other" }]);
|
|
181
|
+
});
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
export interface FakeScope {
|
|
2
|
+
value: Record<string, unknown>;
|
|
3
|
+
listeners: Array<(next: any, prev: any) => void>;
|
|
4
|
+
update: (patch: object) => Promise<void>;
|
|
5
|
+
watch: (callback: (next: any, prev: any) => void | Promise<void>) => () => void;
|
|
6
|
+
}
|
|
7
|
+
export declare function fakeScope(initial: Record<string, unknown>): FakeScope;
|
|
8
|
+
export declare function fakeContext(scope: FakeScope): any;
|
|
9
|
+
export declare function baseValue(): Record<string, unknown>;
|
|
10
|
+
export declare function installCardCommandDriver(ctx: any, resolver: any, registry?: any): Promise<void>;
|
|
11
|
+
export declare function installedMountScope(): Promise<{
|
|
12
|
+
scope: FakeScope;
|
|
13
|
+
calls: Array<[string, unknown]>;
|
|
14
|
+
}>;
|
|
15
|
+
export declare function mountCommand(op: string, mount: Record<string, string>): Record<string, unknown>;
|