@exagone313/dsh-podman 0.2.0-rc.3 → 0.2.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.
Files changed (191) hide show
  1. package/README.md +14 -2
  2. package/README.zh.md +14 -2
  3. package/cordis.patch.yml +58 -0
  4. package/dist/approval-reasons.d.ts +3 -4
  5. package/dist/approval-reasons.js +40 -49
  6. package/dist/approval-reasons.test.js +43 -15
  7. package/dist/approval.js +27 -10
  8. package/dist/approval.test.js +165 -34
  9. package/dist/card-route.d.ts +3 -2
  10. package/dist/card-route.js +154 -42
  11. package/dist/card-route.test.js +85 -43
  12. package/dist/card-test-support.js +39 -66
  13. package/dist/client/ContainerCard.d.ts +1 -1
  14. package/dist/client/ContainerCard.js +92 -50
  15. package/dist/client/card-protocol.d.ts +6 -3
  16. package/dist/client/container-card-caches.js +11 -6
  17. package/dist/client/container-card-controller.d.ts +16 -18
  18. package/dist/client/container-card-controller.js +92 -40
  19. package/dist/client/container-card-create-modal.d.ts +2 -2
  20. package/dist/client/container-card-create-modal.js +13 -5
  21. package/dist/client/container-card-default-env.d.ts +10 -0
  22. package/dist/client/container-card-default-env.js +69 -0
  23. package/dist/client/container-card-directory.d.ts +2 -2
  24. package/dist/client/container-card-directory.js +12 -4
  25. package/dist/client/container-card-editors.d.ts +3 -3
  26. package/dist/client/container-card-editors.js +104 -32
  27. package/dist/client/container-card-images.d.ts +4 -4
  28. package/dist/client/container-card-images.js +19 -6
  29. package/dist/client/container-card-paths.d.ts +3 -3
  30. package/dist/client/container-card-paths.js +18 -11
  31. package/dist/client/container-card-row.d.ts +10 -6
  32. package/dist/client/container-card-row.js +82 -13
  33. package/dist/client/container-card-secrets.d.ts +3 -3
  34. package/dist/client/container-card-secrets.js +8 -6
  35. package/dist/client/container-card-shared.d.ts +4 -17
  36. package/dist/client/container-card-shared.js +9 -25
  37. package/dist/client/container-card-styles.d.ts +2 -8
  38. package/dist/client/container-card-styles.js +14 -52
  39. package/dist/client/container-card-volumes.d.ts +2 -2
  40. package/dist/client/container-card-volumes.js +9 -5
  41. package/dist/client/container-card-workspace.d.ts +8 -6
  42. package/dist/client/container-card-workspace.js +9 -3
  43. package/dist/client/directory-picker.js +2 -1
  44. package/dist/client/index.js +13141 -2036
  45. package/dist/client/locales.d.ts +6 -1
  46. package/dist/client/locales.js +106 -38
  47. package/dist/client/podman-terminal-guide.d.ts +11 -0
  48. package/dist/client/podman-terminal-guide.js +208 -0
  49. package/dist/client/podman-terminal-title.d.ts +7 -0
  50. package/dist/client/podman-terminal-title.js +30 -0
  51. package/dist/client/podman-terminal.d.ts +26 -0
  52. package/dist/client/podman-terminal.js +412 -0
  53. package/dist/client/read-only-approval.d.ts +2 -2
  54. package/dist/client/read-only-approval.js +13 -3
  55. package/dist/client/slot-contract.d.ts +0 -11
  56. package/dist/client/terminal-preference.d.ts +26 -0
  57. package/dist/client/terminal-preference.js +67 -0
  58. package/dist/client/terminal-protocol.d.ts +85 -0
  59. package/dist/client/terminal-protocol.js +14 -0
  60. package/dist/client/terminal-tab.d.ts +60 -0
  61. package/dist/client/terminal-tab.js +35 -0
  62. package/dist/client/terminal-targets.d.ts +33 -0
  63. package/dist/client/terminal-targets.js +65 -0
  64. package/dist/client/terminal-titles.d.ts +23 -0
  65. package/dist/client/terminal-titles.js +52 -0
  66. package/dist/client/terminal-transport.d.ts +63 -0
  67. package/dist/client/terminal-transport.js +207 -0
  68. package/dist/client/tool-views.js +215 -49
  69. package/dist/container-env.d.ts +11 -0
  70. package/dist/container-env.js +96 -0
  71. package/dist/container-env.test.d.ts +1 -0
  72. package/dist/container-env.test.js +93 -0
  73. package/dist/containers.test.js +196 -18
  74. package/dist/daemons.test.js +18 -4
  75. package/dist/dsh-version.d.ts +1 -0
  76. package/dist/dsh-version.js +46 -0
  77. package/dist/env-rows.d.ts +14 -0
  78. package/dist/env-rows.js +52 -0
  79. package/dist/env-rows.test.d.ts +1 -0
  80. package/dist/env-rows.test.js +43 -0
  81. package/dist/fs-provider.d.ts +1 -0
  82. package/dist/fs-provider.js +26 -12
  83. package/dist/fs-provider.test.js +51 -10
  84. package/dist/generated/version.d.ts +2 -2
  85. package/dist/generated/version.js +2 -2
  86. package/dist/grpc/proto/dshctl/v1/control.proto +11 -1
  87. package/dist/grpc/proto/dshguest/v1/guest.proto +5 -0
  88. package/dist/grpc/runtime-client.d.ts +3 -1
  89. package/dist/grpc/runtime-client.js +90 -17
  90. package/dist/guest-rpc.d.ts +15 -19
  91. package/dist/guest-rpc.js +289 -70
  92. package/dist/guest-rpc.test.d.ts +1 -0
  93. package/dist/guest-rpc.test.js +241 -0
  94. package/dist/guest-terminal.d.ts +30 -0
  95. package/dist/guest-terminal.js +196 -0
  96. package/dist/images.test.js +10 -3
  97. package/dist/index.d.ts +11 -16
  98. package/dist/index.js +42 -36
  99. package/dist/locales.test.d.ts +1 -0
  100. package/dist/locales.test.js +34 -0
  101. package/dist/misc.test.js +38 -10
  102. package/dist/mount-enums.js +2 -1
  103. package/dist/mount-input.d.ts +2 -0
  104. package/dist/mount-input.js +38 -22
  105. package/dist/mounts.test.js +81 -11
  106. package/dist/naming.test.d.ts +1 -0
  107. package/dist/naming.test.js +28 -0
  108. package/dist/output-reader.js +36 -4
  109. package/dist/package-deps.test.d.ts +1 -0
  110. package/dist/package-deps.test.js +49 -0
  111. package/dist/paths.test.js +4 -2
  112. package/dist/plugin-meta.test.d.ts +1 -0
  113. package/dist/plugin-meta.test.js +29 -0
  114. package/dist/project-path.js +33 -7
  115. package/dist/project-path.test.js +14 -0
  116. package/dist/prompts.d.ts +0 -4
  117. package/dist/prompts.js +15 -84
  118. package/dist/read-only-shell.js +3 -1
  119. package/dist/read-only-shell.test.js +80 -25
  120. package/dist/runtime-client.test.d.ts +1 -0
  121. package/dist/runtime-client.test.js +53 -0
  122. package/dist/secrets.test.js +21 -6
  123. package/dist/settings-commands.test.js +61 -14
  124. package/dist/settings-create.test.js +32 -10
  125. package/dist/settings-default-env.test.d.ts +1 -0
  126. package/dist/settings-default-env.test.js +175 -0
  127. package/dist/settings-mounts.test.js +46 -7
  128. package/dist/settings-schema.d.ts +6 -11
  129. package/dist/settings-schema.js +4 -8
  130. package/dist/settings-schema.test.d.ts +1 -0
  131. package/dist/settings-schema.test.js +35 -0
  132. package/dist/settings-workspaces.test.js +38 -63
  133. package/dist/spill-store.d.ts +1 -0
  134. package/dist/spill-store.js +17 -4
  135. package/dist/spill-store.test.js +31 -8
  136. package/dist/subprocess.d.ts +4 -0
  137. package/dist/subprocess.js +148 -162
  138. package/dist/subprocess.test.js +118 -8
  139. package/dist/terminal-preference.test.d.ts +1 -0
  140. package/dist/terminal-preference.test.js +62 -0
  141. package/dist/terminal-route.d.ts +10 -0
  142. package/dist/terminal-route.js +270 -0
  143. package/dist/terminal-route.test.d.ts +1 -0
  144. package/dist/terminal-route.test.js +265 -0
  145. package/dist/terminal-sessions.d.ts +69 -0
  146. package/dist/terminal-sessions.js +252 -0
  147. package/dist/terminal-shells.d.ts +22 -0
  148. package/dist/terminal-shells.js +99 -0
  149. package/dist/terminal-tab.test.d.ts +1 -0
  150. package/dist/terminal-tab.test.js +58 -0
  151. package/dist/terminal-targets.test.d.ts +1 -0
  152. package/dist/terminal-targets.test.js +43 -0
  153. package/dist/terminal-titles.test.d.ts +1 -0
  154. package/dist/terminal-titles.test.js +36 -0
  155. package/dist/test-support.d.ts +35 -5
  156. package/dist/test-support.js +91 -29
  157. package/dist/tool-defs.js +19 -4
  158. package/dist/tool-handlers.js +80 -28
  159. package/dist/tool-params.js +19 -5
  160. package/dist/version-compat.d.ts +4 -0
  161. package/dist/version-compat.js +29 -0
  162. package/dist/version-compat.test.d.ts +1 -0
  163. package/dist/version-compat.test.js +27 -0
  164. package/dist/volumes.test.js +1 -1
  165. package/dist/workspace-binding.d.ts +19 -10
  166. package/dist/workspace-binding.js +252 -78
  167. package/dist/workspace-binding.test.js +202 -27
  168. package/docs/architecture.md +71 -20
  169. package/docs/architecture.zh.md +62 -12
  170. package/docs/configuration.md +61 -20
  171. package/docs/configuration.zh.md +53 -19
  172. package/docs/development-prompt.md +72 -0
  173. package/docs/development-prompt.zh.md +69 -0
  174. package/docs/development.md +184 -29
  175. package/docs/development.zh.md +165 -26
  176. package/docs/install-dsh-and-dsh-podman.md +24 -21
  177. package/docs/install-dsh-and-dsh-podman.zh.md +20 -21
  178. package/docs/uninstall.md +83 -0
  179. package/docs/uninstall.zh.md +79 -0
  180. package/docs/update.md +120 -0
  181. package/docs/update.zh.md +114 -0
  182. package/docs/usage.md +108 -61
  183. package/docs/usage.zh.md +76 -38
  184. package/locale/en.json +6 -0
  185. package/locale/zh.json +6 -0
  186. package/package.json +58 -25
  187. package/quadlet/dsh-podman-orchestrator.container +46 -0
  188. package/quadlet/dsh.container +35 -0
  189. package/LICENSE.pkg +0 -17103
  190. package/dist/preferences.d.ts +0 -6
  191. package/dist/preferences.js +0 -27
@@ -6,11 +6,26 @@
6
6
  // request, so neither the snapshot nor the commands touch the persisted
7
7
  // settings document.
8
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";
9
+ import { renderCacheCleanNotice, renderDefaultEnvSyncNotice, resolveReasonLocale, } from "./approval-reasons.js";
10
+ import { mergeDefaultEnv, missingDefaultEnv } from "./container-env.js";
11
+ import { defaultMountMode, mountKindToProto, mountModeToProto, } from "./mount-enums.js";
12
+ import { mountInputToProto, validateMountInput } from "./mount-input.js";
11
13
  import { unaryGuest } from "./guest-rpc.js";
12
14
  import { workspaceSlug } from "./workspace-binding.js";
13
15
  import { GIT_COMMIT, VERSION } from "./generated/version.js";
16
+ import { versionState as pluginVersionState } from "./version-compat.js";
17
+ import { dshVersion } from "./dsh-version.js";
18
+ // readOrchestratorVersion asks the orchestrator for its version, or returns ""
19
+ // when the call fails (an orchestrator predating the handshake).
20
+ async function readOrchestratorVersion(resolver) {
21
+ try {
22
+ const response = await resolver.control("getVersion", {});
23
+ return String(response?.version ?? "");
24
+ }
25
+ catch {
26
+ return "";
27
+ }
28
+ }
14
29
  // The cache clean modes the settings card can request, mapped to the control
15
30
  // plane's enum names.
16
31
  const CACHE_CLEAN_MODES = {
@@ -25,17 +40,32 @@ function cacheCleanModeToProto(mode) {
25
40
  throw new Error(`unknown cache clean mode: ${mode}`);
26
41
  return proto;
27
42
  }
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;
43
+ // containerMountView and imageView rebuild the snapshot's nested objects from
44
+ // an explicit allow-list, so a field added to the proto later cannot reach the
45
+ // browser just because it exists on the wire (see AGENTS.md "Security").
46
+ function containerMountView(mount) {
47
+ return {
48
+ projectName: stringField(mount?.projectName),
49
+ destination: stringField(mount?.destination),
50
+ kind: stringField(mount?.kind),
51
+ mode: stringField(mount?.mode),
52
+ volume: stringField(mount?.volume),
53
+ secret: stringField(mount?.secret),
54
+ };
55
+ }
56
+ function imageView(image) {
57
+ return {
58
+ imageId: stringField(image?.imageId),
59
+ parent: stringField(image?.parent),
60
+ packages: Array.isArray(image?.packages) ? image.packages.map(String) : [],
61
+ imageTag: stringField(image?.imageTag),
62
+ builtAt: stringField(image?.builtAt),
63
+ isBase: Boolean(image?.isBase),
64
+ status: stringField(image?.status),
65
+ primitive: stringField(image?.primitive),
66
+ packageManager: stringField(image?.packageManager),
67
+ basePublic: Boolean(image?.basePublic),
68
+ };
39
69
  }
40
70
  function orchestratorWorkspaceViews(raw) {
41
71
  return (raw ?? []).map((workspace) => ({
@@ -63,12 +93,15 @@ function dshWorkspaceViews(registry, projectsRoot) {
63
93
  : path;
64
94
  return {
65
95
  workspaceSlug: workspaceSlug(workspace.id),
66
- projectName: projectName || String(workspace.title ?? "") || String(workspace.id ?? ""),
96
+ projectName: projectName || String(workspace.title ?? "") ||
97
+ String(workspace.id ?? ""),
67
98
  containerName: "",
68
99
  imageId: "",
69
100
  status: "",
70
101
  createdAt: workspace.createdAt ?? "",
71
- mounts: projectName !== "" ? [{ projectName, mode: "MOUNT_MODE_READ_WRITE" }] : [],
102
+ mounts: projectName !== ""
103
+ ? [{ projectName, mode: "MOUNT_MODE_READ_WRITE" }]
104
+ : [],
72
105
  };
73
106
  });
74
107
  }
@@ -99,17 +132,49 @@ function dshWorkspaceCwd(registry, slug) {
99
132
  }
100
133
  /** Build the live snapshot the card renders. */
101
134
  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
- ]);
135
+ // The handshake RPC is exempt from the orchestrator's version check, so it
136
+ // succeeds even when the rest is refused; an older orchestrator that has no
137
+ // GetVersion yet leaves the version unknown.
138
+ const orchestratorVersion = await readOrchestratorVersion(resolver);
139
+ let listings;
140
+ try {
141
+ listings = await Promise.all([
142
+ resolver.control("listContainers", {}),
143
+ resolver.control("listImages", {}),
144
+ resolver.control("listWorkspaces", {}),
145
+ resolver.control("listVolumes", {}),
146
+ resolver.control("listSecrets", {}),
147
+ resolver.control("listCaches", {}),
148
+ ]);
149
+ }
150
+ catch (error) {
151
+ // An incompatible plugin is refused before any handler runs, so the card
152
+ // reports the mismatch instead of an unexplained empty snapshot.
153
+ if (error?.code !== "FailedPrecondition") {
154
+ throw error;
155
+ }
156
+ return {
157
+ version: VERSION,
158
+ commit: GIT_COMMIT,
159
+ dshVersion: dshVersion(),
160
+ orchestratorVersion,
161
+ versionState: "major-mismatch",
162
+ projectsRoot: resolver.getConfig().projectsRoot,
163
+ workspaces: [],
164
+ containers: [],
165
+ images: [],
166
+ volumes: [],
167
+ secrets: [],
168
+ caches: [],
169
+ };
170
+ }
171
+ const [containers, images, workspaces, volumes, secrets, caches] = listings;
110
172
  return {
111
173
  version: VERSION,
112
174
  commit: GIT_COMMIT,
175
+ dshVersion: dshVersion(),
176
+ orchestratorVersion,
177
+ versionState: pluginVersionState(VERSION, orchestratorVersion),
113
178
  projectsRoot: resolver.getConfig().projectsRoot,
114
179
  workspaces: mergeWorkspaceViews(dshWorkspaceViews(workspaceRegistry, resolver.getConfig().projectsRoot), orchestratorWorkspaceViews(workspaces.workspaces)),
115
180
  containers: (containers.containers ?? []).map((container) => ({
@@ -118,12 +183,12 @@ export async function cardSnapshot(resolver, workspaceRegistry) {
118
183
  imageId: container.imageId ?? "",
119
184
  status: container.status ?? "",
120
185
  createdAt: container.createdAt ?? "",
121
- mounts: container.mounts ?? [],
186
+ mounts: (container.mounts ?? []).map(containerMountView),
122
187
  paths: container.paths ?? [],
123
188
  env: container.env ?? {},
124
189
  secretEnv: container.secretEnv ?? {},
125
190
  })),
126
- images: images.images ?? [],
191
+ images: (images.images ?? []).map(imageView),
127
192
  volumes: (volumes.volumes ?? []).map((volume) => ({
128
193
  name: volume.name ?? "",
129
194
  })),
@@ -143,7 +208,9 @@ export async function cardSnapshot(resolver, workspaceRegistry) {
143
208
  * Run one card command, returning the notice to show (usually empty). `ctx` is
144
209
  * the settings-injected context, read only for the session locale.
145
210
  */
146
- export async function runCommand(ctx, resolver, workspaceRegistry, raw) {
211
+ export async function runCommand(_ctx, resolver, workspaceRegistry, raw,
212
+ // The live UI locale, read per notice; absent in tests, which render English.
213
+ readLocale) {
147
214
  const command = normalizeCommand(raw);
148
215
  let notice = "";
149
216
  switch (command.op) {
@@ -152,13 +219,18 @@ export async function runCommand(ctx, resolver, workspaceRegistry, raw) {
152
219
  workspaceSlug: command.workspace,
153
220
  imageId: command.image === "" ? undefined : command.image,
154
221
  };
155
- const mounts = command.mounts.map(mountInputToProto);
222
+ const projectsRoot = resolver.getConfig().projectsRoot;
223
+ const mounts = command.mounts.map((mount) => mountInputToProto(mount, projectsRoot));
156
224
  if (mounts.length > 0)
157
225
  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;
226
+ // The card's create is a creation path: seed the default environment
227
+ // under whatever the modal collected. A later recreate is authoritative,
228
+ // which is how a seeded value is removed again.
229
+ const env = mergeDefaultEnv(resolver.getConfig().containerEnv, command.env);
230
+ if (Object.keys(env).length > 0)
231
+ payload.env = env;
232
+ if (Object.keys(command.secretEnv).length > 0) {
233
+ payload.secretEnv = command.secretEnv;
162
234
  }
163
235
  if (command.paths.length > 0)
164
236
  payload.paths = [...command.paths];
@@ -180,6 +252,7 @@ export async function runCommand(ctx, resolver, workspaceRegistry, raw) {
180
252
  const m = command.mount;
181
253
  if (m === null)
182
254
  break;
255
+ validateMountInput(m, resolver.getConfig().projectsRoot);
183
256
  const kind = mountKindToProto(m.kind || undefined);
184
257
  const mode = mountModeToProto(m.mode || defaultMountMode(m.kind || undefined));
185
258
  const request = {
@@ -238,6 +311,9 @@ export async function runCommand(ctx, resolver, workspaceRegistry, raw) {
238
311
  const m = command.mount;
239
312
  if (m === null)
240
313
  break;
314
+ // An update sets a mode, so a secret mount must not be made read-write
315
+ // here either.
316
+ validateMountInput(m, resolver.getConfig().projectsRoot);
241
317
  const kind = mountKindToProto(m.kind || undefined);
242
318
  const request = {
243
319
  workspaceSlug: command.workspace,
@@ -277,6 +353,7 @@ export async function runCommand(ctx, resolver, workspaceRegistry, raw) {
277
353
  case "remove":
278
354
  await resolver.control("removeContainer", {
279
355
  workspaceSlug: command.workspace,
356
+ ...(command.container !== "" ? { container: command.container } : {}),
280
357
  });
281
358
  break;
282
359
  case "workspace_remove":
@@ -287,6 +364,7 @@ export async function runCommand(ctx, resolver, workspaceRegistry, raw) {
287
364
  case "recreate":
288
365
  await resolver.control("recreateContainer", {
289
366
  workspaceSlug: command.workspace,
367
+ ...(command.container !== "" ? { container: command.container } : {}),
290
368
  imageId: command.image === "" ? undefined : command.image,
291
369
  ...(Object.keys(command.env).length > 0 ? { env: command.env } : {}),
292
370
  });
@@ -326,7 +404,7 @@ export async function runCommand(ctx, resolver, workspaceRegistry, raw) {
326
404
  await resolver.control("addContainerSecret", {
327
405
  workspaceSlug: command.workspace,
328
406
  container: command.container || "default",
329
- env: command.secretEnv,
407
+ env: command.secretEnvName,
330
408
  secret: command.secret,
331
409
  });
332
410
  break;
@@ -334,7 +412,7 @@ export async function runCommand(ctx, resolver, workspaceRegistry, raw) {
334
412
  await resolver.control("removeContainerSecret", {
335
413
  workspaceSlug: command.workspace,
336
414
  container: command.container || "default",
337
- env: command.secretEnv,
415
+ env: command.secretEnvName,
338
416
  });
339
417
  break;
340
418
  case "image_build":
@@ -350,11 +428,44 @@ export async function runCommand(ctx, resolver, workspaceRegistry, raw) {
350
428
  case "image_base_pull":
351
429
  await resolver.control("pullBaseImage", { name: command.workspace });
352
430
  break;
431
+ case "default_env_sync": {
432
+ // Add the default environment to containers that already exist. Only the
433
+ // keys a container is missing are added, so an explicit value is never
434
+ // overwritten, and a stopped container is left for its next start (which
435
+ // seeds the defaults). A recreate applies the merged map.
436
+ const defaults = resolver.getConfig().containerEnv ?? {};
437
+ if (Object.keys(defaults).length === 0)
438
+ break;
439
+ const listings = await resolver.control("listContainers", {});
440
+ let applied = 0;
441
+ let skipped = 0;
442
+ for (const row of listings.containers ?? []) {
443
+ if (command.workspace !== "" && row.workspaceSlug !== command.workspace) {
444
+ continue;
445
+ }
446
+ const current = (row.env ?? {});
447
+ const missing = missingDefaultEnv(defaults, current);
448
+ if (Object.keys(missing).length === 0)
449
+ continue;
450
+ if (row.status !== "running") {
451
+ skipped++;
452
+ continue;
453
+ }
454
+ await resolver.control("recreateContainer", {
455
+ workspaceSlug: row.workspaceSlug,
456
+ container: row.containerName,
457
+ env: mergeDefaultEnv(defaults, current),
458
+ });
459
+ applied++;
460
+ }
461
+ notice = renderDefaultEnvSyncNotice(resolveReasonLocale(readLocale?.()), applied, skipped);
462
+ break;
463
+ }
353
464
  case "cache_clean": {
354
465
  const result = (await resolver.control("cleanCaches", {
355
466
  mode: cacheCleanModeToProto(command.cacheMode),
356
467
  }));
357
- notice = renderCacheCleanNotice(resolveReasonLocale(ctx?.settings), Number(result.removedFiles ?? 0));
468
+ notice = renderCacheCleanNotice(resolveReasonLocale(readLocale?.()), Number(result.removedFiles ?? 0));
358
469
  break;
359
470
  }
360
471
  default:
@@ -372,8 +483,9 @@ function stringField(value, fallback = "") {
372
483
  return typeof value === "string" ? value : fallback;
373
484
  }
374
485
  function stringMap(value) {
375
- if (typeof value !== "object" || value === null || Array.isArray(value))
486
+ if (typeof value !== "object" || value === null || Array.isArray(value)) {
376
487
  return {};
488
+ }
377
489
  const result = {};
378
490
  for (const [key, item] of Object.entries(value)) {
379
491
  result[key] = String(item);
@@ -416,16 +528,16 @@ function normalizeCommand(raw) {
416
528
  env: stringMap(raw.env),
417
529
  container: stringField(raw.container),
418
530
  secret: stringField(raw.secret),
419
- secretEnv: stringField(raw.secretEnv),
531
+ secretEnvName: stringField(raw.secretEnvName),
420
532
  length: typeof raw.length === "number" ? raw.length : 0,
421
533
  charset: stringField(raw.charset),
422
534
  packages: Array.isArray(raw.packages) ? raw.packages.map(String) : [],
423
- secretEnvMap: stringMap(raw.secretEnvMap),
535
+ secretEnv: stringMap(raw.secretEnv),
424
536
  cacheMode: stringField(raw.cacheMode),
425
537
  mount: mountField(raw.mount),
426
538
  };
427
539
  }
428
- async function handleCardRequest(request, ctx, resolver, workspaceRegistry) {
540
+ async function handleCardRequest(request, ctx, resolver, workspaceRegistry, readLocale) {
429
541
  if (request.method === "GET") {
430
542
  try {
431
543
  return jsonResponse(200, await cardSnapshot(resolver, workspaceRegistry));
@@ -451,7 +563,7 @@ async function handleCardRequest(request, ctx, resolver, workspaceRegistry) {
451
563
  return jsonResponse(400, { error: "a command is required" });
452
564
  }
453
565
  try {
454
- const notice = await runCommand(ctx, resolver, workspaceRegistry, command);
566
+ const notice = await runCommand(ctx, resolver, workspaceRegistry, command, readLocale);
455
567
  const result = notice === "" ? {} : { notice };
456
568
  return jsonResponse(200, result);
457
569
  }
@@ -466,13 +578,13 @@ async function handleCardRequest(request, ctx, resolver, workspaceRegistry) {
466
578
  * harness API path, so the carrier applies its Host/Origin fence and browser
467
579
  * authentication before the handler runs.
468
580
  */
469
- export function registerCardRoute(ctx, resolver, workspaceRegistry) {
470
- ctx.inject(["connection", "settings"], (connectionCtx) => {
581
+ export function registerCardRoute(ctx, resolver, workspaceRegistry, readLocale) {
582
+ ctx.inject(["connection"], (connectionCtx) => {
471
583
  connectionCtx.connection.fetch.register({
472
584
  path: CARD_PATH,
473
585
  methods: ["GET", "POST"],
474
586
  requestBody: "buffered",
475
- fetch: (request) => handleCardRequest(request, connectionCtx, resolver, workspaceRegistry),
587
+ fetch: (request) => handleCardRequest(request, connectionCtx, resolver, workspaceRegistry, readLocale),
476
588
  });
477
589
  });
478
590
  }
@@ -5,7 +5,6 @@ import { test } from "node:test";
5
5
  import { strict as assert } from "node:assert";
6
6
  import { CARD_PATH } from "./client/card-protocol.js";
7
7
  import { registerCardRoute } from "./card-route.js";
8
- import { installContainerPreferences } from "./preferences.js";
9
8
  function fakeResolver() {
10
9
  const calls = [];
11
10
  const resolver = {
@@ -17,8 +16,11 @@ function fakeResolver() {
17
16
  setConfig: () => { },
18
17
  async control(method, request) {
19
18
  calls.push([method, request]);
20
- if (method === "listContainers")
19
+ if (method === "getVersion")
20
+ return { version: "9.9.9", commit: "abc" };
21
+ if (method === "listContainers") {
21
22
  return { containers: [{ containerName: "c1" }] };
23
+ }
22
24
  if (method === "listImages")
23
25
  return { images: [{ imageId: "img1" }] };
24
26
  if (method === "listWorkspaces")
@@ -27,8 +29,9 @@ function fakeResolver() {
27
29
  return { volumes: [{ name: "data" }] };
28
30
  if (method === "listSecrets")
29
31
  return { secrets: [{ name: "s1" }] };
30
- if (method === "listCaches")
32
+ if (method === "listCaches") {
31
33
  return { caches: [{ manager: "pacman", files: 1, bytes: "1024" }] };
34
+ }
32
35
  if (method === "cleanCaches")
33
36
  return { removedFiles: 2 };
34
37
  if (method === "removeContainer")
@@ -42,7 +45,7 @@ function fakeRouteContext() {
42
45
  const routes = [];
43
46
  const ctx = {
44
47
  inject(deps, callback) {
45
- assert.deepEqual(deps, ["connection", "settings"]);
48
+ assert.deepEqual(deps, ["connection"]);
46
49
  callback({
47
50
  connection: {
48
51
  fetch: {
@@ -57,10 +60,6 @@ function fakeRouteContext() {
57
60
  },
58
61
  },
59
62
  },
60
- settings: {
61
- register: () => ({ watch: () => () => { } }),
62
- get: () => undefined,
63
- },
64
63
  });
65
64
  },
66
65
  };
@@ -92,7 +91,38 @@ test("the card route serves the live snapshot on GET", async () => {
92
91
  assert.deepEqual(snapshot.volumes, [{ name: "data" }]);
93
92
  assert.deepEqual(snapshot.secrets, [{ name: "s1" }]);
94
93
  assert.equal(snapshot.caches[0].bytes, 1024);
95
- assert.deepEqual(calls.map(([method]) => method), ["listContainers", "listImages", "listWorkspaces", "listVolumes", "listSecrets", "listCaches"]);
94
+ assert.deepEqual(calls.map(([method]) => method), [
95
+ "getVersion",
96
+ "listContainers",
97
+ "listImages",
98
+ "listWorkspaces",
99
+ "listVolumes",
100
+ "listSecrets",
101
+ "listCaches",
102
+ ]);
103
+ assert.equal(typeof snapshot.dshVersion, "string");
104
+ assert.equal(snapshot.orchestratorVersion, "9.9.9");
105
+ assert.equal(snapshot.versionState, "major-mismatch", "a differing major is surfaced on the card");
106
+ });
107
+ test("a refused control call reports the version mismatch instead of an empty snapshot", async () => {
108
+ const { resolver } = fakeResolver();
109
+ const refusal = new Error("plugin is incompatible");
110
+ refusal.code = "FailedPrecondition";
111
+ resolver.control = async (method) => {
112
+ if (method === "getVersion")
113
+ return { version: "9.9.9", commit: "abc" };
114
+ throw refusal;
115
+ };
116
+ const { ctx, routes } = fakeRouteContext();
117
+ registerCardRoute(ctx, resolver);
118
+ const response = await routes[0].fetch(cardRequest());
119
+ assert.equal(response.status, 200);
120
+ const snapshot = (await response.json());
121
+ assert.equal(typeof snapshot.dshVersion, "string");
122
+ assert.equal(snapshot.orchestratorVersion, "9.9.9");
123
+ assert.equal(snapshot.versionState, "major-mismatch");
124
+ assert.deepEqual(snapshot.containers, []);
125
+ assert.deepEqual(snapshot.workspaces, []);
96
126
  });
97
127
  test("the card route runs one command per POST and returns its notice", async () => {
98
128
  const { resolver, calls } = fakeResolver();
@@ -104,9 +134,51 @@ test("the card route runs one command per POST and returns its notice", async ()
104
134
  }));
105
135
  assert.equal(remove.status, 200);
106
136
  assert.deepEqual(await remove.json(), {});
107
- assert.deepEqual(calls.find(([method]) => method === "removeContainer")?.[1], {
137
+ // A command that names no container keeps the orchestrator's default; one that
138
+ // names a container must reach it, or a row's Remove would hit another one.
139
+ const removeNamed = await routes[0].fetch(cardRequest({
140
+ method: "POST",
141
+ body: JSON.stringify({
142
+ command: { op: "remove", workspace: "w1", container: "foo" },
143
+ }),
144
+ }));
145
+ assert.equal(removeNamed.status, 200);
146
+ const removeCalls = calls.filter(([method]) => method === "removeContainer");
147
+ assert.deepEqual(removeCalls[0]?.[1], { workspaceSlug: "w1" });
148
+ assert.deepEqual(removeCalls[1]?.[1], {
149
+ workspaceSlug: "w1",
150
+ container: "foo",
151
+ });
152
+ const recreate = await routes[0].fetch(cardRequest({
153
+ method: "POST",
154
+ body: JSON.stringify({
155
+ command: {
156
+ op: "recreate",
157
+ workspace: "w1",
158
+ container: "foo",
159
+ image: "img1",
160
+ },
161
+ }),
162
+ }));
163
+ assert.equal(recreate.status, 200);
164
+ assert.deepEqual(calls.find(([method]) => method === "recreateContainer")?.[1], {
108
165
  workspaceSlug: "w1",
166
+ container: "foo",
167
+ imageId: "img1",
109
168
  });
169
+ const detach = await routes[0].fetch(cardRequest({
170
+ method: "POST",
171
+ body: JSON.stringify({
172
+ command: {
173
+ op: "container_secret_remove",
174
+ workspace: "w1",
175
+ container: "foo",
176
+ secretEnvName: "TOKEN",
177
+ },
178
+ }),
179
+ }));
180
+ assert.equal(detach.status, 200);
181
+ assert.deepEqual(calls.find(([method]) => method === "removeContainerSecret")?.[1], { workspaceSlug: "w1", container: "foo", env: "TOKEN" });
110
182
  const clean = await routes[0].fetch(cardRequest({
111
183
  method: "POST",
112
184
  body: JSON.stringify({ command: { op: "cache_clean", cacheMode: "all" } }),
@@ -145,37 +217,7 @@ test("the card route reports a failed command as an error", async () => {
145
217
  body: JSON.stringify({ command: { op: "remove", workspace: "w1" } }),
146
218
  }));
147
219
  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" }]);
220
+ assert.deepEqual(await response.json(), {
221
+ error: "control plane unavailable",
222
+ });
181
223
  });
@@ -6,8 +6,6 @@
6
6
  // `runCommand` and folds the real `cardSnapshot` back into the fake scope, so
7
7
  // the command tests stay focused on the command-to-control mapping. The HTTP
8
8
  // layer itself is covered by card-route.test.ts.
9
- import { strict as assert } from "node:assert";
10
- import { CONTAINER_NS, settingsSchema } from "./settings-schema.js";
11
9
  import { cardSnapshot, runCommand } from "./card-route.js";
12
10
  export function fakeScope(initial) {
13
11
  const scope = {
@@ -30,26 +28,14 @@ export function fakeScope(initial) {
30
28
  return scope;
31
29
  }
32
30
  export function fakeContext(scope) {
33
- return {
34
- inject(deps, callback) {
35
- assert.deepEqual(deps, ["settings"]);
36
- callback({
37
- settings: {
38
- register() {
39
- return scope;
40
- },
41
- get() {
42
- return undefined;
43
- },
44
- },
45
- });
46
- },
47
- };
31
+ // The host half no longer registers a settings namespace: the card's
32
+ // preferences are volatile plugin-config fields, so the fake document is
33
+ // handed to the driver directly.
34
+ return { fakeScope: scope };
48
35
  }
49
36
  export function baseValue() {
50
37
  return {
51
38
  defaultImage: "archlinux",
52
- socketsRoot: "/run/dsh-podman",
53
39
  uiLocale: "",
54
40
  notice: "",
55
41
  command: null,
@@ -61,62 +47,49 @@ export function baseValue() {
61
47
  caches: [],
62
48
  };
63
49
  }
64
- // installCardCommandDriver wires a fake settings scope to the real command
50
+ // installCardCommandDriver wires a fake settings document to the real command
65
51
  // runner and snapshot builder, standing in for the browser transport: it
66
52
  // mirrors what the card does per request — run the command, then refresh the
67
53
  // live snapshot and record the notice.
68
54
  export function installCardCommandDriver(ctx, resolver, registry) {
69
- let installed = Promise.resolve();
70
- ctx.inject(["settings"], (sctx) => {
71
- const scope = sctx.settings.register(CONTAINER_NS, settingsSchema, {
72
- base: {
73
- defaultImage: resolver.getConfig().defaultImage,
74
- socketsRoot: resolver.getConfig().socketsRoot,
75
- },
76
- });
77
- const refresh = async (notice = "") => {
78
- let view = {};
55
+ const scope = ctx.fakeScope;
56
+ const refresh = async (notice = "") => {
57
+ let view = {};
58
+ try {
59
+ const snapshot = await cardSnapshot(resolver, registry);
60
+ view = {
61
+ workspaces: snapshot.workspaces,
62
+ containers: snapshot.containers,
63
+ images: snapshot.images,
64
+ volumes: snapshot.volumes,
65
+ secrets: snapshot.secrets,
66
+ caches: snapshot.caches,
67
+ };
68
+ }
69
+ catch {
70
+ // Command tests do not always stub every list call.
71
+ }
72
+ await scope.update({ ...view, notice });
73
+ };
74
+ scope.watch((next) => {
75
+ const command = next.command;
76
+ if (command === null || command === undefined)
77
+ return;
78
+ void (async () => {
79
+ // Clear the command before running it, so the refresh below cannot
80
+ // re-deliver it.
81
+ await scope.update({ command: null });
82
+ let notice = "";
79
83
  try {
80
- const snapshot = await cardSnapshot(resolver, registry);
81
- view = {
82
- workspaces: snapshot.workspaces,
83
- containers: snapshot.containers,
84
- images: snapshot.images,
85
- volumes: snapshot.volumes,
86
- secrets: snapshot.secrets,
87
- caches: snapshot.caches,
88
- };
84
+ notice = await runCommand(ctx, resolver, registry, command);
89
85
  }
90
- catch {
91
- // Command tests do not always stub every list call.
86
+ catch (error) {
87
+ notice = error instanceof Error ? error.message : String(error);
92
88
  }
93
- await scope.update({ ...view, notice });
94
- };
95
- scope.watch((next) => {
96
- resolver.setConfig({
97
- defaultImage: next.defaultImage,
98
- socketsRoot: next.socketsRoot,
99
- });
100
- const command = next.command;
101
- if (command === null || command === undefined)
102
- return;
103
- void (async () => {
104
- // Clear the command before running it, so the refresh below cannot
105
- // re-deliver it.
106
- await scope.update({ command: null });
107
- let notice = "";
108
- try {
109
- notice = await runCommand(sctx, resolver, registry, command);
110
- }
111
- catch (error) {
112
- notice = error instanceof Error ? error.message : String(error);
113
- }
114
- await refresh(notice);
115
- })();
116
- });
117
- installed = refresh();
89
+ await refresh(notice);
90
+ })();
118
91
  });
119
- return installed;
92
+ return refresh();
120
93
  }
121
94
  export async function installedMountScope() {
122
95
  const scope = fakeScope(baseValue());