@synopackageland/cli 0.7.0 → 0.7.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.
Files changed (165) hide show
  1. package/CHANGELOG.md +301 -1
  2. package/assets/default-icon/README.md +19 -0
  3. package/assets/default-icon/default-icon-small.svg +9 -0
  4. package/assets/default-icon/default-icon.svg +10 -0
  5. package/assets/default-icon/icon_16.png +0 -0
  6. package/assets/default-icon/icon_24.png +0 -0
  7. package/assets/default-icon/icon_256.png +0 -0
  8. package/assets/default-icon/icon_32.png +0 -0
  9. package/assets/default-icon/icon_48.png +0 -0
  10. package/assets/default-icon/icon_64.png +0 -0
  11. package/assets/default-icon/icon_72.png +0 -0
  12. package/dist/add.d.ts +4 -1
  13. package/dist/add.js +14 -9
  14. package/dist/build-spk/dsm-icons.d.ts +31 -2
  15. package/dist/build-spk/dsm-icons.js +135 -56
  16. package/dist/build-spk/dsm-platforms.json +4 -1
  17. package/dist/build-spk/gateway.d.ts +1 -1
  18. package/dist/build-spk/gateway.js +8 -4
  19. package/dist/build-spk/generator.js +17 -3
  20. package/dist/build-spk/go-toolchain.d.ts +20 -0
  21. package/dist/build-spk/go-toolchain.js +44 -0
  22. package/dist/build-spk/png.d.ts +14 -0
  23. package/dist/build-spk/png.js +161 -0
  24. package/dist/build-spk/ui-texts.d.ts +20 -0
  25. package/dist/build-spk/ui-texts.js +42 -0
  26. package/dist/cli.js +39 -9
  27. package/dist/contract/i18n.d.ts +5 -0
  28. package/dist/contract/i18n.js +45 -0
  29. package/dist/contract/icon.d.ts +5 -0
  30. package/dist/contract/icon.js +27 -0
  31. package/dist/contract/validate.js +4 -0
  32. package/dist/contract/web-launch.js +6 -2
  33. package/dist/deploy/native-install-values.d.ts +17 -0
  34. package/dist/deploy/native-install-values.js +32 -0
  35. package/dist/deploy/plan.d.ts +6 -2
  36. package/dist/deploy/plan.js +57 -6
  37. package/dist/error-registry.js +15 -6
  38. package/dist/host/dsm-client.js +53 -17
  39. package/dist/host/dsm-http.js +24 -2
  40. package/dist/host/types.d.ts +1 -0
  41. package/dist/info.js +28 -9
  42. package/dist/init.js +1 -1
  43. package/dist/runtime/dsm-version.d.ts +6 -0
  44. package/dist/runtime/dsm-version.js +8 -0
  45. package/dist/runtime/executor-build.d.ts +1 -1
  46. package/dist/runtime/executor-build.js +8 -5
  47. package/dist/standalone/daemon.d.ts +2 -1
  48. package/dist/standalone/daemon.js +10 -6
  49. package/dist/standalone/dsm-window.d.ts +2 -0
  50. package/dist/standalone/dsm-window.js +26 -40
  51. package/dist/standalone/i18n.d.ts +12 -0
  52. package/dist/standalone/i18n.js +30 -0
  53. package/dist/standalone/inspect-spk.d.ts +5 -0
  54. package/dist/standalone/inspect-spk.js +41 -2
  55. package/dist/standalone/manifest.d.ts +5 -6
  56. package/dist/standalone/manifest.js +21 -34
  57. package/dist/standalone/native-authorization-window.d.ts +1 -1
  58. package/dist/standalone/native-authorization-window.js +7 -1
  59. package/dist/standalone/native-layout.d.ts +2 -0
  60. package/dist/standalone/native-layout.js +22 -1
  61. package/dist/standalone/native-root-window.d.ts +4 -2
  62. package/dist/standalone/native-root-window.js +33 -18
  63. package/dist/standalone/root-window.d.ts +4 -1
  64. package/dist/standalone/root-window.js +143 -33
  65. package/dist/standalone/spk.js +7 -4
  66. package/dist/templates/_daemon/go.mod +1 -1
  67. package/dist/templates/_daemon/main.go +6 -3
  68. package/dist/templates/_daemon/main_test.go +21 -1
  69. package/dist/templates/_daemon/testdata/child.go +3 -0
  70. package/dist/templates/_executor/README.md +4 -1
  71. package/dist/templates/_executor/anchor.go +10 -0
  72. package/dist/templates/_executor/anchor_test.go +40 -5
  73. package/dist/templates/_executor/digest_review_test.go +2 -2
  74. package/dist/templates/_executor/dsm.go +40 -3
  75. package/dist/templates/_executor/go.mod +2 -0
  76. package/dist/templates/_executor/main.go +11 -5
  77. package/dist/templates/_executor/managed.go +3 -3
  78. package/dist/templates/_executor/managed_test.go +3 -3
  79. package/dist/templates/_executor/path_test.go +18 -0
  80. package/dist/templates/_executor/pid.go +37 -0
  81. package/dist/templates/_executor/pid_test.go +113 -0
  82. package/dist/templates/_executor/platform_other.go +8 -0
  83. package/dist/templates/_executor/purge_log.go +18 -0
  84. package/dist/templates/_executor/tasks.go +45 -7
  85. package/dist/templates/_executor/tasks_dsm71_test.go +114 -0
  86. package/dist/templates/_executor/tasks_test.go +58 -1
  87. package/dist/templates/_executor/upgrade_watch_test.go +13 -5
  88. package/dist/templates/_gateway/go.mod +2 -0
  89. package/dist/templates/binary/dsm-package.yaml +3 -0
  90. package/dist/templates/compose/synology-app.yaml +3 -0
  91. package/dist/templates/native-go/backend/go.mod +1 -1
  92. package/dist/templates/native-go/backend/internal/filestation/client.go +39 -3
  93. package/dist/templates/native-go/backend/internal/filestation/download_dsm71_test.go +82 -0
  94. package/dist/templates/native-go/backend/internal/identity/README.md +5 -1
  95. package/dist/templates/native-go/backend/internal/identity/dsm.go +24 -5
  96. package/dist/templates/native-go/backend/internal/identity/http.go +1 -1
  97. package/dist/templates/native-go/backend/internal/identity/identity_test.go +48 -1
  98. package/dist/templates/native-go/backend/internal/identity/service.go +1 -1
  99. package/dist/templates/native-go/dsm-package.yaml +3 -0
  100. package/dist/templates/native-go-delegate/frontend/test/delegate.test.ts +15 -0
  101. package/dist/templates/native-go-root-ops/backend/internal/app/executor.go +30 -2
  102. package/dist/templates/native-go-root-ops/backend/internal/app/executor_test.go +64 -0
  103. package/dist/templates/native-go-vue/frontend/src/delegate/ExecutorSettings.vue +3 -2
  104. package/dist/templates/native-go-vue/frontend/src/delegate/executor-settings.test.ts +23 -0
  105. package/dist/templates/native-go-vue/frontend/src/delegate/executor-setup.ts +5 -1
  106. package/dist/templates/native-go-vue/frontend/src/delegate/i18n/de.json +1 -0
  107. package/dist/templates/native-go-vue/frontend/src/delegate/i18n/en.json +1 -0
  108. package/dist/templates/native-go-vue/frontend/src/delegate/i18n/es.json +1 -0
  109. package/dist/templates/native-go-vue/frontend/src/delegate/i18n/fr.json +1 -0
  110. package/dist/templates/native-go-vue/frontend/src/delegate/i18n/it.json +1 -0
  111. package/dist/templates/native-go-vue/frontend/src/delegate/i18n/ja.json +1 -0
  112. package/dist/templates/native-go-vue/frontend/src/delegate/i18n/ko.json +1 -0
  113. package/dist/templates/native-go-vue/frontend/src/delegate/i18n/zh-CN.json +1 -0
  114. package/dist/templates/native-go-vue/frontend/src/delegate/i18n/zh-TW.json +1 -0
  115. package/package.json +2 -1
  116. package/schema/synology-app.schema.json +22 -0
  117. package/templates/_daemon/go.mod +1 -1
  118. package/templates/_daemon/main.go +6 -3
  119. package/templates/_daemon/main_test.go +21 -1
  120. package/templates/_daemon/testdata/child.go +3 -0
  121. package/templates/_executor/README.md +4 -1
  122. package/templates/_executor/anchor.go +10 -0
  123. package/templates/_executor/anchor_test.go +40 -5
  124. package/templates/_executor/digest_review_test.go +2 -2
  125. package/templates/_executor/dsm.go +40 -3
  126. package/templates/_executor/go.mod +2 -0
  127. package/templates/_executor/main.go +11 -5
  128. package/templates/_executor/managed.go +3 -3
  129. package/templates/_executor/managed_test.go +3 -3
  130. package/templates/_executor/path_test.go +18 -0
  131. package/templates/_executor/pid.go +37 -0
  132. package/templates/_executor/pid_test.go +113 -0
  133. package/templates/_executor/platform_other.go +8 -0
  134. package/templates/_executor/purge_log.go +18 -0
  135. package/templates/_executor/tasks.go +45 -7
  136. package/templates/_executor/tasks_dsm71_test.go +114 -0
  137. package/templates/_executor/tasks_test.go +58 -1
  138. package/templates/_executor/upgrade_watch_test.go +13 -5
  139. package/templates/_gateway/go.mod +2 -0
  140. package/templates/binary/dsm-package.yaml +3 -0
  141. package/templates/compose/synology-app.yaml +3 -0
  142. package/templates/native-go/backend/go.mod +1 -1
  143. package/templates/native-go/backend/internal/filestation/client.go +39 -3
  144. package/templates/native-go/backend/internal/filestation/download_dsm71_test.go +82 -0
  145. package/templates/native-go/backend/internal/identity/README.md +5 -1
  146. package/templates/native-go/backend/internal/identity/dsm.go +24 -5
  147. package/templates/native-go/backend/internal/identity/http.go +1 -1
  148. package/templates/native-go/backend/internal/identity/identity_test.go +48 -1
  149. package/templates/native-go/backend/internal/identity/service.go +1 -1
  150. package/templates/native-go/dsm-package.yaml +3 -0
  151. package/templates/native-go-delegate/frontend/test/delegate.test.ts +15 -0
  152. package/templates/native-go-root-ops/backend/internal/app/executor.go +30 -2
  153. package/templates/native-go-root-ops/backend/internal/app/executor_test.go +64 -0
  154. package/templates/native-go-vue/frontend/src/delegate/ExecutorSettings.vue +3 -2
  155. package/templates/native-go-vue/frontend/src/delegate/executor-settings.test.ts +23 -0
  156. package/templates/native-go-vue/frontend/src/delegate/executor-setup.ts +5 -1
  157. package/templates/native-go-vue/frontend/src/delegate/i18n/de.json +1 -0
  158. package/templates/native-go-vue/frontend/src/delegate/i18n/en.json +1 -0
  159. package/templates/native-go-vue/frontend/src/delegate/i18n/es.json +1 -0
  160. package/templates/native-go-vue/frontend/src/delegate/i18n/fr.json +1 -0
  161. package/templates/native-go-vue/frontend/src/delegate/i18n/it.json +1 -0
  162. package/templates/native-go-vue/frontend/src/delegate/i18n/ja.json +1 -0
  163. package/templates/native-go-vue/frontend/src/delegate/i18n/ko.json +1 -0
  164. package/templates/native-go-vue/frontend/src/delegate/i18n/zh-CN.json +1 -0
  165. package/templates/native-go-vue/frontend/src/delegate/i18n/zh-TW.json +1 -0
@@ -1,23 +1,47 @@
1
1
  import { bootDefinition, controlDefinition } from '../runtime/executor-tasks.js';
2
2
  import { executorScopeTask } from '../runtime/executor-scope-task.js';
3
+ import { versionUnsupported } from '../runtime/dsm-version.js';
3
4
  export { executorTasks as rootTasks } from '../runtime/executor-tasks.js';
4
5
  /** Exact readback is authoritative. PasswordConfirm tokens are reused immediately,
5
6
  * across both Root APIs, and never persisted (DSM validity is about 300 seconds). */
6
7
  export const ROOT_RUNTIME = `function createRootAccess(options) {
7
8
  var eventAPI = "SYNO.Core.EventScheduler", taskAPI = "SYNO.Core.TaskScheduler";
8
- var busy = null;
9
- function fail(code) { var e = new Error(code); e.code = code; return e; }
9
+ var busy = null, legacy = false;
10
+ var versionUnsupported = ${versionUnsupported.toString()};
11
+ function fail(code, detail) { var e = new Error(code); e.code = code; if (detail) e.detail = detail; return e; }
10
12
  function reason(e) {
11
13
  var code = String(e && e.code || "executor_bad_response");
14
+ if (e && e.passwordConfirm) return code + (code === "dsm_3121" ? ": DSM rejected the administrator password." : ": administrator password confirmation failed.") + " Nothing was created.";
15
+ if (e && e.detail) return code + ": " + e.detail;
12
16
  return code + (code === "105" || code === "dsm_105" ? ": DSM denied administrator scheduler access. Check the DSM session and scheduler; a NAS restart resolved this on the measured DSM. No automatic retry was made." : ": scheduler readback or operation failed.");
13
17
  }
14
18
  // AppWindow.sendWebAPI encodes JSON parameters. Preserve their native types;
15
19
  // pre-encoding turns objects/booleans into strings and DSM rejects create.
16
20
  function send(api, method, version, params) { return options.send(api, method, version, params || {}); }
21
+ // DSM 7.1 has no Root APIs: TaskScheduler stops at list v2 (offset) and get v3.
22
+ // Only an unsupported method version on a read selects that profile, which
23
+ // then creates through the plain scheduler APIs. Writes never fall back.
24
+ function taskRead(method, version, params, legacyVersion, legacyParams) {
25
+ if (legacy) return send(taskAPI, method, legacyVersion, legacyParams);
26
+ return send(taskAPI, method, version, params).catch(function (e) {
27
+ if (!versionUnsupported(e && e.code)) throw e;
28
+ legacy = true;
29
+ return send(taskAPI, method, legacyVersion, legacyParams);
30
+ });
31
+ }
32
+ function create(boot, params, confirmation) {
33
+ if (legacy) return send(boot ? eventAPI : taskAPI, "create", boot ? 1 : 3, params);
34
+ params.SynoConfirmPWToken = confirmation.token;
35
+ return send(boot ? eventAPI + ".Root" : taskAPI + ".Root", "create", boot ? 1 : 4, params);
36
+ }
37
+ function remove(task, ref) {
38
+ var boot = task.kind === "boot";
39
+ return send(boot ? eventAPI : taskAPI, "delete", boot ? 1 : 2, boot ? ref : {tasks: [ref]});
40
+ }
17
41
  function admin() { if (!options.user() || options.user().admin !== true) throw fail("admin_required"); }
18
42
  function find(task) {
19
43
  var boot = task.kind === "boot";
20
- return send(boot ? eventAPI : taskAPI, "list", boot ? 1 : 3, boot ? {} : {start: 0, limit: 500}).then(function (data) {
44
+ return (boot ? send(eventAPI, "list", 1, {}) : taskRead("list", 3, {start: 0, limit: 500}, 2, {offset: 0, limit: 500})).then(function (data) {
21
45
  var records = boot ? data : data && data.tasks;
22
46
  if (!Array.isArray(records) || records.length > 500 || (!boot && (!Number.isInteger(data.total) || data.total !== records.length))) throw fail("executor_bad_response");
23
47
  var named = records.filter(function (r) { return r && (boot ? r.task_name : r.name) === task.name; });
@@ -26,7 +50,7 @@ export const ROOT_RUNTIME = `function createRootAccess(options) {
26
50
  var summary = named[0];
27
51
  if (!boot && !/^[0-9]+$/.test(String(summary.id))) throw fail("executor_bad_response");
28
52
  var ref = boot ? {task_name: task.name} : {id: summary.id, real_owner: "root"};
29
- var detail = send(boot ? eventAPI : taskAPI, "get", boot ? 1 : 4, ref);
53
+ var detail = boot ? send(eventAPI, "get", 1, ref) : taskRead("get", 4, ref, 3, ref);
30
54
  return detail.then(function (r) {
31
55
  var owner = r && r.owner;
32
56
  var root = owner === "root" || (boot && owner && owner["0"] === "root");
@@ -49,6 +73,16 @@ export const ROOT_RUNTIME = `function createRootAccess(options) {
49
73
  return result;
50
74
  });
51
75
  }
76
+ // Removes, newest first, the tasks one approval created and still reads as
77
+ // exactly ours. Resolves with the last refusal's reason, if any.
78
+ function withdraw(created) {
79
+ var failed;
80
+ return created.reduceRight(function (withdrawn, task) {
81
+ return withdrawn.then(function () { return find(task); }).then(function (current) {
82
+ if (current.state === "ready") return remove(task, current.ref);
83
+ }).catch(function (e) { failed = reason(e); });
84
+ }, Promise.resolve()).then(function () { return failed; });
85
+ }
52
86
  function definition(task) { return task.kind === "boot" ? (${bootDefinition.toString()})(task) : (${controlDefinition.toString()})(task); }
53
87
  function approve(password, selectedScope) {
54
88
  var selectionTask;
@@ -56,10 +90,13 @@ export const ROOT_RUNTIME = `function createRootAccess(options) {
56
90
  return read().then(function (before) {
57
91
  if (before.state === "blocked" || before.state === "unsupported") return before;
58
92
  if (before.state === "ready" && !selectionTask) return start(before);
59
- return options.confirmPassword(password).then(function (confirmation) {
93
+ return options.confirmPassword(password).catch(function (e) {
94
+ throw {code: e && e.code, passwordConfirm: true};
95
+ }).then(function (confirmation) {
60
96
  if (!confirmation || confirmation.confirmed !== true) return {state: "cancelled", tasks: before.tasks};
61
- if (typeof confirmation.token !== "string" || !confirmation.token) throw fail("executor_confirmation_missing");
62
- var errors = {};
97
+ // DSM 7.1 confirms the password server-side but issues no create token.
98
+ if (!legacy && (typeof confirmation.token !== "string" || !confirmation.token)) throw fail("executor_confirmation_missing");
99
+ var errors = {}, created = [], withdrawFailed;
63
100
  // Delete only a verified, same-header outdated task, then create the pinned
64
101
  // replacement. set has no measured wire contract. Re-read before each write.
65
102
  var chain = Promise.resolve();
@@ -68,22 +105,35 @@ export const ROOT_RUNTIME = `function createRootAccess(options) {
68
105
  return find(task).then(function (current) {
69
106
  if (current.state === "ready") return;
70
107
  if (current.state === "blocked") throw fail(current.reason);
71
- var boot = task.kind === "boot";
72
- var removed = current.ref ? send(boot ? eventAPI : taskAPI, "delete", boot ? 1 : 2, boot ? current.ref : {tasks: [current.ref]}) : Promise.resolve();
73
- return removed.then(function () {
74
- var params = definition(task); params.SynoConfirmPWToken = confirmation.token;
75
- return send(boot ? eventAPI + ".Root" : taskAPI + ".Root", "create", boot ? 1 : 4, params);
76
- });
108
+ var removed = current.ref ? remove(task, current.ref) : Promise.resolve();
109
+ return removed.then(function () { return create(task.kind === "boot", definition(task), confirmation); }).then(function () { created.push(task); });
77
110
  }).catch(function (e) { errors[task.kind] = reason(e); });
78
111
  });
79
112
  });
80
- return chain.then(read).then(function (after) {
113
+ // A partial approval must not leave an enabled root bootup task that no
114
+ // control task or uninstall inventory owns: withdraw what it created.
115
+ return chain.then(function () {
116
+ if (Object.keys(errors).length) return withdraw(created).then(function (failed) { withdrawFailed = failed; });
117
+ }).then(read).then(function (after) {
81
118
  Object.keys(errors).forEach(function (kind) {
82
119
  if (after.tasks[kind].state !== "ready") { after.tasks[kind].state = "blocked"; after.tasks[kind].reason = errors[kind]; after.state = "blocked"; after.reason = errors[kind]; }
83
120
  });
121
+ if (withdrawFailed) { after.state = "blocked"; after.reason = (after.reason ? after.reason + " " : "") + "Withdrawing the tasks this approval created failed: " + withdrawFailed; }
84
122
  // A scope change must not restart a running executor: the boot task
85
123
  // stops it, which interrupts in-flight copies instead of canceling them.
86
124
  return after.state === "ready" ? (selectionTask ? running(after) : Promise.resolve(null)).then(function (live) { return live || start(after); }).then(function (started) {
125
+ // Without a running executor there is no task inventory, so uninstall
126
+ // cannot find these tasks: withdraw what this approval created.
127
+ if (started.state === "ready" || !created.length) return started;
128
+ return withdraw(created).then(function (failed) {
129
+ return read().then(function (current) {
130
+ current.state = "blocked";
131
+ current.reason = started.reason + (failed ? " Withdrawing the tasks this approval created failed: " + failed : " The tasks this approval created were withdrawn.");
132
+ current.retry = !failed;
133
+ return current;
134
+ });
135
+ });
136
+ }).then(function (started) {
87
137
  if (!selectionTask || started.state !== "ready") return started;
88
138
  var generation = started.executor.selection ? started.executor.selection.generation : 0;
89
139
  if (started.executor.selection && (!Number.isSafeInteger(generation) || generation <= 0)) throw fail("executor_selection_invalid");
@@ -102,23 +152,20 @@ export const ROOT_RUNTIME = `function createRootAccess(options) {
102
152
  function selectScope(task, confirmation, verified, selectedScope) {
103
153
  function removeExact() {
104
154
  return find(task).then(function (current) {
105
- if (current.state === "ready") return send(taskAPI, "delete", 2, {tasks: [current.ref]});
155
+ if (current.state === "ready") return remove(task, current.ref);
106
156
  if (current.ref) throw fail("executor_selection_cleanup_conflict");
107
157
  });
108
158
  }
109
159
  return find(task).then(function (current) {
110
160
  if (current.state === "blocked" || current.state === "unsupported") throw fail("executor_selection_conflict");
111
- var removed = current.ref ? send(taskAPI, "delete", 2, {tasks: [current.ref]}) : Promise.resolve();
112
- return removed.then(function () {
113
- var params = definition(task); params.SynoConfirmPWToken = confirmation.token;
114
- return send(taskAPI + ".Root", "create", 4, params);
115
- });
161
+ var removed = current.ref ? remove(task, current.ref) : Promise.resolve();
162
+ return removed.then(function () { return create(false, definition(task), confirmation); });
116
163
  }).then(function () { return find(task); }).then(function (current) {
117
164
  if (current.state !== "ready") throw fail("executor_selection_readback_mismatch");
118
165
  return send(taskAPI, "run", 2, {tasks: [current.ref]});
119
166
  }).then(function () {
120
167
  function readSelection(attempts) {
121
- return health(verified, 1).then(function (current) {
168
+ return health(verified, 0).then(function (current) {
122
169
  if (current.state === "ready" && current.executor.selection && current.executor.selection.scope === selectedScope && Number.isSafeInteger(current.executor.selection.generation) && current.executor.selection.generation > 0) return current;
123
170
  if (attempts > 1 && options.wait) return options.wait(500).then(function () { verified.state = "ready"; return readSelection(attempts - 1); });
124
171
  throw fail("executor_selection_unconfirmed");
@@ -129,21 +176,35 @@ export const ROOT_RUNTIME = `function createRootAccess(options) {
129
176
  return removeExact().then(function () { throw error; });
130
177
  });
131
178
  }
132
- function pinned(status) { return !!status && status.ready === true && !!options.pins && status.executor_digest === options.pins.executorDigest && status.policy_digest === options.pins.policyDigest; }
179
+ // Why a status reply is not the approved, running executor; null when it is.
180
+ function mismatch(status) {
181
+ if (!status || status.ready !== true) return "the approved executor is not answering" + (status && (status.reason || status.state) ? " (" + (status.reason || status.state) + ")" : "") + ".";
182
+ if (!options.pins || status.executor_digest !== options.pins.executorDigest || status.policy_digest !== options.pins.policyDigest) return "the running executor reports other digests than this package pins.";
183
+ return null;
184
+ }
185
+ function pinned(status) { return mismatch(status) === null; }
133
186
  function running(verified) {
134
187
  return options.call("executor.status", {}).then(function (status) {
135
188
  if (!pinned(status)) return null;
136
189
  verified.executor = status; return verified;
137
190
  }, function () { return null; });
138
191
  }
139
- function health(verified, attempts) {
192
+ // budget: how long to keep asking after a start (ms). The wait doubles from
193
+ // 500 ms to 2 s so an open window does not poll the executor twice a second.
194
+ function health(verified, budget, delay) {
140
195
  if (verified.state !== "ready") return Promise.resolve(verified);
196
+ delay = Math.min(delay || 500, 2000);
141
197
  return options.call("executor.status", {}).then(function (status) {
142
- if (!pinned(status)) throw fail("executor_status_mismatch");
198
+ var why = mismatch(status);
199
+ if (why) throw fail("executor_status_mismatch", why);
143
200
  verified.executor = status; return verified;
201
+ }, function (e) {
202
+ throw fail(e && e.code, "the executor status request failed.");
144
203
  }).catch(function (e) {
145
- if (attempts > 1 && options.wait) return options.wait(500).then(function () { return health(verified, attempts - 1); });
146
- verified.state = "blocked"; verified.reason = reason(e); return verified;
204
+ if (budget >= delay && options.wait) return options.wait(delay).then(function () { return health(verified, budget - delay, delay * 2); });
205
+ // The tasks are approved: approving again runs start, which restarts the
206
+ // executor without a password, so the panel offers it.
207
+ verified.state = "blocked"; verified.reason = reason(e); verified.retry = true; return verified;
147
208
  });
148
209
  }
149
210
  function start(verified) {
@@ -151,13 +212,17 @@ export const ROOT_RUNTIME = `function createRootAccess(options) {
151
212
  return send(eventAPI, "run", 1, verified.tasks.boot.ref);
152
213
  }).then(function () {
153
214
  // run can succeed for a nonexistent task. Read both definitions again.
154
- return read().then(function (after) { return health(after, 20); });
215
+ return read().then(function (after) { return health(after, 10000); });
155
216
  }).catch(function (e) { verified.state = "blocked"; verified.reason = reason(e); return verified; });
156
217
  }
218
+ // The window panel and the app's own settings share this access; each
219
+ // result reaches every view so neither shows a stale state.
220
+ var listeners = [];
221
+ function publish(result) { listeners.forEach(function (listener) { listener(result); }); return result; }
157
222
  return {
158
- status: function () { return read().then(function (verified) { return health(verified, 1); }); },
223
+ status: function () { return read().then(function (verified) { return health(verified, 0); }).then(publish); },
159
224
  authorize: function (password, selectedScope) {
160
- if (!busy) busy = Promise.resolve().then(function () { return approve(password, selectedScope); }).catch(function (e) { return {state: "blocked", reason: reason(e), tasks: {}}; }).then(function (r) { busy = null; return r; });
225
+ if (!busy) busy = Promise.resolve().then(function () { return approve(password, selectedScope); }).catch(function (e) { return {state: "blocked", reason: reason(e), tasks: {}, retry: !!(e && e.passwordConfirm)}; }).then(function (r) { busy = null; return publish(r); });
161
226
  return busy;
162
227
  },
163
228
  control: function (action) {
@@ -166,9 +231,10 @@ export const ROOT_RUNTIME = `function createRootAccess(options) {
166
231
  if (verified.state !== "ready") throw fail("executor_not_ready");
167
232
  return options.call("executor.control", {action: action}).then(function () {
168
233
  return send(taskAPI, "run", 2, {tasks: [verified.tasks.control.ref]});
169
- }).then(read).then(function (after) { return action === "start" ? health(after, 20) : after; });
170
- });
171
- }
234
+ }).then(read).then(function (after) { return action === "start" ? health(after, 10000) : after; });
235
+ }).then(publish);
236
+ },
237
+ subscribe: function (listener) { listeners.push(listener); }
172
238
  };
173
239
  }`;
174
240
  export const ROOT_PANEL = `function mountRootPanel(host, access, i18n, tasks, admin) {
@@ -176,9 +242,53 @@ export const ROOT_PANEL = `function mountRootPanel(host, access, i18n, tasks, ad
176
242
  var status = document.createElement("p"), approve = document.createElement("button"), retry = document.createElement("button");
177
243
  approve.textContent = "Approve background access"; retry.textContent = "Check status";
178
244
  host.appendChild(status); host.appendChild(approve); host.appendChild(retry);
179
- function show(result) { status.textContent = result.state + (result.reason ? ": " + result.reason : ""); approve.hidden = result.state !== "needs-admin-approval"; }
245
+ function show(result) { status.textContent = result.state + (result.reason ? ": " + result.reason : ""); approve.hidden = result.state !== "needs-admin-approval" && result.retry !== true; }
180
246
  function run(fn) { approve.disabled = retry.disabled = true; return fn().then(show, function(e) { show({state: "blocked", reason: e.message || e.code}); }).then(function() { approve.disabled = retry.disabled = false; }); }
247
+ access.subscribe(show);
181
248
  approve.onclick = function() { return run(function() { return access.authorize(); }); };
182
249
  retry.onclick = function() { return run(function() { return access.status(); }); };
183
250
  run(function() { return access.status().then(function(result) { return result.tasks && result.tasks.boot && result.tasks.control && result.tasks.boot.state === "ready" && result.tasks.control.state === "ready" ? access.control("start") : result; }); });
184
251
  }`;
252
+ /** Administrator password confirmation in the window's own session: a supplied
253
+ * password, or a masked form when none is given. */
254
+ export const PASSWORD_CONFIRM = `function createPasswordConfirm(win) {
255
+ var versionUnsupported = ${versionUnsupported.toString()};
256
+ function send(password, version) {
257
+ return new Promise(function (resolve, reject) {
258
+ win.sendWebAPI({api: "SYNO.Core.User.PasswordConfirm", method: "auth", version: version, params: {password: password}, scope: win,
259
+ callback: function (success, response) {
260
+ if (!success) reject({code: "dsm_" + ((response && response.code) || "error")});
261
+ else resolve({confirmed: true, token: response && response.SynoConfirmPWToken});
262
+ }
263
+ });
264
+ });
265
+ }
266
+ // DSM 7.1 has PasswordConfirm v1 only, which checks the password but issues no
267
+ // token. Only an unsupported version is retried; a wrong password is not.
268
+ function auth(password) {
269
+ return send(password, 2).catch(function (error) {
270
+ if (!versionUnsupported(error.code)) throw error;
271
+ return send(password, 1);
272
+ });
273
+ }
274
+ return function (suppliedPassword) {
275
+ if (typeof suppliedPassword === "string" && suppliedPassword) return auth(suppliedPassword);
276
+ return new Promise(function (resolve, reject) {
277
+ var form = document.createElement("form"), label = document.createElement("label"), input = document.createElement("input");
278
+ var submit = document.createElement("button"), cancel = document.createElement("button");
279
+ label.textContent = "Confirm your DSM administrator password";
280
+ input.type = "password"; input.autocomplete = "current-password"; input.required = true;
281
+ label.appendChild(input); form.appendChild(label);
282
+ submit.type = "submit"; submit.textContent = "Approve"; form.appendChild(submit);
283
+ cancel.type = "button"; cancel.textContent = "Cancel"; form.appendChild(cancel);
284
+ win.body.dom.appendChild(form); input.focus();
285
+ function close() { input.value = ""; if (form.parentNode) form.parentNode.removeChild(form); }
286
+ cancel.onclick = function () { close(); resolve({confirmed: false}); };
287
+ form.onsubmit = function (event) {
288
+ event.preventDefault(); submit.disabled = cancel.disabled = true;
289
+ var password = input.value; input.value = "";
290
+ auth(password).then(function (result) { close(); resolve(result); }, function (error) { close(); reject(error); });
291
+ };
292
+ });
293
+ };
294
+ }`;
@@ -4,7 +4,7 @@ import { chmodSync, copyFileSync, existsSync, lstatSync, mkdirSync, mkdtempSync,
4
4
  import { tmpdir } from "node:os";
5
5
  import { dirname, join } from "node:path";
6
6
  import { infoArchValue } from "../build-spk/dsm-arch.js";
7
- import { writeDsmWindowIcons, writePackageCenterIcons } from "../build-spk/dsm-icons.js";
7
+ import { packageIcons, writeDsmWindowIcons, writePackageCenterIcons } from "../build-spk/dsm-icons.js";
8
8
  import { renderNativePrivilege } from "../build-spk/native-lifecycle.js";
9
9
  import { renderUninstallWizardFiles } from "../build-spk/uninstall-wizard.js";
10
10
  import { renderInstallWizardFiles, renderNativeInstallWizard } from "../build-spk/install-wizard.js";
@@ -13,7 +13,8 @@ import { renderDelegateScopeSteps } from '../build-spk/delegate-scope.js';
13
13
  import { createReproducibleTar } from "../build-spk/reproducible-archive.js";
14
14
  import { renderStandaloneScript } from "./lifecycle.js";
15
15
  import { DSM_LANGS, LOCALES } from "./i18n.js";
16
- import { CGI_PAYLOAD_PATH, renderUiConfig, renderWindowScript, windowScriptName } from "./dsm-window.js";
16
+ import { appTitle, CGI_PAYLOAD_PATH, renderUiConfig, renderWindowScript, windowScriptName } from "./dsm-window.js";
17
+ import { writeUiTexts } from "../build-spk/ui-texts.js";
17
18
  import { copyStandaloneNotices } from "../build-spk/notices.js";
18
19
  import { hostScriptName, renderHostAdapterScript } from "./host-adapter.js";
19
20
  import { copyDaemonPayload } from "./daemon.js";
@@ -54,6 +55,7 @@ export async function assembleStandaloneSpk(options) {
54
55
  try {
55
56
  if ((manifest.capabilities.rootOps || manifest.capabilities.delegate) && !options.executor)
56
57
  throw new StandaloneBuildError("EXECUTOR_PIN_REQUIRED", "Executor artifact required.");
58
+ const icons = packageIcons(manifest.icon);
57
59
  const uiRoot = join(staging, "package", "ui");
58
60
  const appDir = join(uiRoot, "app");
59
61
  copyUiTree(manifest.ui.dir, appDir);
@@ -61,7 +63,8 @@ export async function assembleStandaloneSpk(options) {
61
63
  writeFile(join(uiRoot, "config"), renderUiConfig(manifest));
62
64
  writeFile(join(uiRoot, windowScriptName(manifest)), renderWindowScript(manifest, assetVersion));
63
65
  writeFile(join(uiRoot, hostScriptName(manifest.build)), renderHostAdapterScript(manifest.package));
64
- writeDsmWindowIcons(uiRoot);
66
+ writeDsmWindowIcons(uiRoot, icons);
67
+ writeUiTexts(uiRoot, appTitle(manifest));
65
68
  const cgiTarget = join(staging, "package", CGI_PAYLOAD_PATH);
66
69
  mkdirSync(dirname(cgiTarget), { recursive: true });
67
70
  copyFileSync(options.cgiPath, cgiTarget);
@@ -129,7 +132,7 @@ export async function assembleStandaloneSpk(options) {
129
132
  ...(folder ? renderSharedFolderUpgradeWizardFiles(manifest.package, folder) : {}),
130
133
  ...renderUninstallWizardFiles(manifest.package, "native", { executor: Boolean(options.executor) }),
131
134
  });
132
- writePackageCenterIcons(staging);
135
+ writePackageCenterIcons(staging, icons);
133
136
  mkdirSync(dirname(options.outputPath), { recursive: true });
134
137
  await createReproducibleTar(staging, options.outputPath);
135
138
  }
@@ -2,4 +2,4 @@ module synopkgland-daemon
2
2
 
3
3
  go 1.24
4
4
 
5
- toolchain go1.26.8
5
+ toolchain go1.26.9
@@ -28,6 +28,9 @@ type deadlines struct {
28
28
  Start int `json:"start"`
29
29
  Stop int `json:"stop"`
30
30
  Status int `json:"status"`
31
+ // Uninstall bounds uninstall_args separately from Stop: a revoke on a
32
+ // loaded NAS can take longer than stopping the daemon.
33
+ Uninstall int `json:"uninstall"`
31
34
  }
32
35
  type config struct {
33
36
  Package string `json:"package"`
@@ -88,7 +91,7 @@ func load(dest, data string) (*controller, error) {
88
91
  if cfg.Exec == "" || filepath.IsAbs(cfg.Exec) || filepath.Clean(cfg.Exec) != cfg.Exec || strings.HasPrefix(cfg.Exec, "../") {
89
92
  return nil, errors.New("invalid fixed executable")
90
93
  }
91
- if cfg.Timeouts.Start < 2 || cfg.Timeouts.Start > 120 || cfg.Timeouts.Stop < 2 || cfg.Timeouts.Stop > 120 || cfg.Timeouts.Status < 1 || cfg.Timeouts.Status > 10 {
94
+ if cfg.Timeouts.Start < 2 || cfg.Timeouts.Start > 120 || cfg.Timeouts.Stop < 2 || cfg.Timeouts.Stop > 120 || cfg.Timeouts.Status < 1 || cfg.Timeouts.Status > 10 || cfg.Timeouts.Uninstall < 2 || cfg.Timeouts.Uninstall > 120 {
92
95
  return nil, errors.New("invalid daemon deadlines")
93
96
  }
94
97
  hash := sha256.Sum256(raw)
@@ -162,7 +165,7 @@ func (c *controller) run(action string) (int, error) {
162
165
  budget += c.Timeouts.Start
163
166
  }
164
167
  if action == "uninstall" {
165
- budget += c.Timeouts.Stop + 1
168
+ budget += c.Timeouts.Uninstall + 2
166
169
  }
167
170
  if action != "start" && action != "stop" && action != "status" && action != "restart" && action != "uninstall" {
168
171
  return 1, errors.New("unsupported daemon action")
@@ -414,7 +417,7 @@ func (c *controller) uninstall() error {
414
417
  select {
415
418
  case err := <-done:
416
419
  return err
417
- case <-time.After(time.Duration(c.Timeouts.Stop-1) * time.Second):
420
+ case <-time.After(time.Duration(c.Timeouts.Uninstall) * time.Second):
418
421
  cmd.Process.Signal(syscall.SIGTERM)
419
422
  }
420
423
  select {
@@ -50,7 +50,7 @@ func fixture(t *testing.T, mode string) (string, string, string) {
50
50
  if raw, err := exec.Command(app, "cleanup").CombinedOutput(); err != nil {
51
51
  t.Fatalf("payload warm-up: %v %s", err, raw)
52
52
  }
53
- cfg := config{Package: "DaemonFixture", Exec: "app", Args: []string{"${SYNOPKG_PKGVAR}/a space", "$(touch BAD)'", "${SYNOPKG_PKGDEST}"}, Env: map[string]string{"APP_MODE": mode}, Timeouts: deadlines{Start: 2, Stop: 2, Status: 1}}
53
+ cfg := config{Package: "DaemonFixture", Exec: "app", Args: []string{"${SYNOPKG_PKGVAR}/a space", "$(touch BAD)'", "${SYNOPKG_PKGDEST}"}, Env: map[string]string{"APP_MODE": mode}, Timeouts: deadlines{Start: 2, Stop: 2, Status: 1, Uninstall: 2}}
54
54
  if os.Geteuid() == 0 {
55
55
  cfg.Package = "nobody"
56
56
  } else {
@@ -250,6 +250,26 @@ func TestUninstallCleanup(t *testing.T) {
250
250
  }
251
251
  }
252
252
 
253
+ // A revoke on uninstall can outlast the stop deadline on a loaded NAS; it has
254
+ // its own budget instead of Stop-1.
255
+ func TestUninstallCommandHasItsOwnBudget(t *testing.T) {
256
+ control, dest, data := fixture(t, "normal")
257
+ path := filepath.Join(dest, "daemon/config.json")
258
+ raw, _ := os.ReadFile(path)
259
+ var cfg config
260
+ json.Unmarshal(raw, &cfg)
261
+ cfg.UninstallArgs = []string{"cleanup-slow"}
262
+ cfg.Timeouts.Uninstall = 5
263
+ raw, _ = json.Marshal(cfg)
264
+ os.WriteFile(path, raw, 0644)
265
+ if rc, out := runControl(control, "start", dest, data); rc != 0 {
266
+ t.Fatalf("start: %d %s", rc, out)
267
+ }
268
+ if rc, out := runControl(control, "uninstall", dest, data); rc != 0 {
269
+ t.Fatalf("slow uninstall within its budget failed: %d %s", rc, out)
270
+ }
271
+ }
272
+
253
273
  func TestPackageAccountIdentity(t *testing.T) {
254
274
  control, dest, data := fixture(t, "normal")
255
275
  path := filepath.Join(dest, "daemon/config.json")
@@ -14,6 +14,9 @@ func main() {
14
14
  switch os.Args[1] {
15
15
  case "cleanup":
16
16
  os.Exit(0)
17
+ case "cleanup-slow":
18
+ time.Sleep(2500 * time.Millisecond)
19
+ os.Exit(0)
17
20
  case "cleanup-fail":
18
21
  os.Exit(2)
19
22
  case "cleanup-hang":
@@ -174,7 +174,10 @@ root-owned `/var/packages/<package>/INFO`, the root monitor copies it into
174
174
  `runtime/`, verifies the copy and runs root `control start`, which replaces the
175
175
  monitor without reapproval. Any other change stays paused for administrator
176
176
  approval. Only root's pinned boot/control path or this anchored install can
177
- replace a monitor or runtime.
177
+ replace a monitor or runtime. To exercise this path on a NAS without a real
178
+ executor change, the repository's `scripts/test/executor-bump.py` makes a
179
+ build+1 SPK whose executor differs by one byte and rewrites the INFO anchor,
180
+ `executor/setup.json` and the window script's embedded pins together.
178
181
 
179
182
  At start, scheduler readback must exactly match the canonical lifecycle scripts
180
183
  regenerated from the package identity and approved policy pin. Package
@@ -84,6 +84,11 @@ func installAnchored(ctx context.Context, source, info string, trusted func(os.F
84
84
  if err != nil {
85
85
  return err
86
86
  }
87
+ // DSM links the package target to the volume; openRegular refuses links.
88
+ source, err = filepath.EvalSymlinks(source)
89
+ if err != nil {
90
+ return err
91
+ }
87
92
  src, err := openRegular(filepath.Join(source, "executor"))
88
93
  if err != nil {
89
94
  return err
@@ -129,7 +134,12 @@ func startInstalled(runtime, pkg string) error {
129
134
  return cmd.Process.Release()
130
135
  }
131
136
 
137
+ // An anchored upgrade is handed to root start, which replaces this monitor:
138
+ // only differing pins are an approval request, and only they say so.
132
139
  func approvedPins(state pinState, install, start func() error, log func(string)) bool {
140
+ if state == pinsDiffer {
141
+ log("executor paused: package pins require administrator approval")
142
+ }
133
143
  if state != pinsAnchored {
134
144
  return state == pinsMatch
135
145
  }
@@ -5,6 +5,7 @@ import (
5
5
  "errors"
6
6
  "os"
7
7
  "path/filepath"
8
+ "reflect"
8
9
  "strings"
9
10
  "testing"
10
11
  )
@@ -101,6 +102,8 @@ func TestInstallAnchoredReplacesOnlyTheProtectedExecutorWithVerifiedBytes(t *tes
101
102
  }
102
103
  }
103
104
 
105
+ // The pause message names administrator approval only when the pins differ:
106
+ // an anchored handover is a finished upgrade, not a request for approval.
104
107
  func TestApprovedPinsHandOverAnAnchoredUpgradeToRootStart(t *testing.T) {
105
108
  for _, tc := range []struct {
106
109
  name string
@@ -109,18 +112,50 @@ func TestApprovedPinsHandOverAnAnchoredUpgradeToRootStart(t *testing.T) {
109
112
  open bool
110
113
  installs int
111
114
  starts int
115
+ logged string
112
116
  }{
113
- {"match", pinsMatch, nil, true, 0, 0},
114
- {"differ", pinsDiffer, nil, false, 0, 0},
115
- {"anchored", pinsAnchored, nil, false, 1, 1},
116
- {"install failed", pinsAnchored, errors.New("digest"), false, 1, 0},
117
+ {"match", pinsMatch, nil, true, 0, 0, ""},
118
+ {"differ", pinsDiffer, nil, false, 0, 0, "executor paused: package pins require administrator approval"},
119
+ {"anchored", pinsAnchored, nil, false, 1, 1, "executor upgrade: INFO-anchored executor installed under the approved policy"},
120
+ {"install failed", pinsAnchored, errors.New("digest"), false, 1, 0, "executor upgrade refused: INFO-anchored executor could not be installed"},
117
121
  } {
118
122
  t.Run(tc.name, func(t *testing.T) {
119
123
  installs, starts := 0, 0
120
- open := approvedPins(tc.state, func() error { installs++; return tc.installErr }, func() error { starts++; return nil }, func(string) {})
124
+ var logged []string
125
+ open := approvedPins(tc.state, func() error { installs++; return tc.installErr }, func() error { starts++; return nil }, func(s string) { logged = append(logged, s) })
121
126
  if open != tc.open || installs != tc.installs || starts != tc.starts {
122
127
  t.Fatal("handover", open, installs, starts)
123
128
  }
129
+ if want := []string{tc.logged}; tc.logged == "" {
130
+ if len(logged) != 0 {
131
+ t.Fatal("matching pins logged", logged)
132
+ }
133
+ } else if !reflect.DeepEqual(logged, want) {
134
+ t.Fatal("log", logged, want)
135
+ }
124
136
  })
125
137
  }
126
138
  }
139
+
140
+ // DSM: /var/packages/<pkg>/target links to /volume1/@appstore/<pkg>.
141
+ func TestInstallAnchoredFollowsTheDSMTargetLink(t *testing.T) {
142
+ ctx := context.Background()
143
+ anchor := hashBytes([]byte("new executor"))
144
+ appstore, info := anchoredPackage(t, "new executor", "approved policy", anchor)
145
+ link := filepath.Join(filepath.Dir(info), "linked-target")
146
+ if err := os.Symlink(filepath.Dir(appstore), link); err != nil {
147
+ t.Fatal(err)
148
+ }
149
+ runtime := realTestPath(t, t.TempDir())
150
+ os.WriteFile(filepath.Join(runtime, "executor"), []byte("approved executor"), 0700)
151
+ target := filepath.Join(link, "executor")
152
+ if state := packagePins(ctx, target, info, trustAll, hashBytes([]byte("approved executor")), hashBytes([]byte("approved policy"))); state != pinsAnchored {
153
+ t.Fatal("pin state", state)
154
+ }
155
+ if err := installAnchored(ctx, target, info, trustAll, runtime); err != nil {
156
+ t.Fatal("anchored executor behind the target link refused", err)
157
+ }
158
+ if b, _ := os.ReadFile(filepath.Join(runtime, "executor")); string(b) != "new executor" {
159
+ t.Fatal("installed", string(b))
160
+ }
161
+ }
@@ -32,7 +32,7 @@ func TestReviewLargePackageHashDoesNotBlockShutdown(t *testing.T) {
32
32
  done <- manageService(ctx, make(chan string), func() (func(), error) { return func() {}, nil }, func(ctx context.Context) bool {
33
33
  close(entered)
34
34
  return samePackagePins(ctx, dir, strings.Repeat("a", 64), strings.Repeat("b", 64))
35
- }, func() error { return nil }, func(string) {})
35
+ }, func() error { return nil })
36
36
  }()
37
37
  <-entered
38
38
  time.Sleep(20 * time.Millisecond)
@@ -93,7 +93,7 @@ func TestLifecycleCancelsPendingPinCheck(t *testing.T) {
93
93
  actions := make(chan string)
94
94
  done := make(chan error, 1)
95
95
  go func() {
96
- done <- manageService(ctx, actions, func() (func(), error) { t.Error("opened during canceled check"); return func() {}, nil }, func(ctx context.Context) bool { close(entered); <-ctx.Done(); close(canceled); return false }, func() error { return nil }, func(string) {})
96
+ done <- manageService(ctx, actions, func() (func(), error) { t.Error("opened during canceled check"); return func() {}, nil }, func(ctx context.Context) bool { close(entered); <-ctx.Done(); close(canceled); return false }, func() error { return nil })
97
97
  }()
98
98
  <-entered
99
99
  if action == "shutdown" {
@@ -78,11 +78,32 @@ type envelope struct {
78
78
  Data json.RawMessage `json:"data"`
79
79
  }
80
80
 
81
+ // dsmError keeps the DSM error code of a refused call; the message stays generic.
82
+ type dsmError struct{ Code int }
83
+
84
+ func (*dsmError) Error() string { return "DSM refused" }
85
+
81
86
  func decodeEnvelope(b []byte) (json.RawMessage, error) {
87
+ data, err := decodeSuccess(b)
88
+ if err == nil && (len(data) == 0 || string(data) == "null") {
89
+ return nil, &dsmError{}
90
+ }
91
+ return data, err
92
+ }
93
+
94
+ // decodeSuccess accepts a success without data: scheduler deletes answer
95
+ // {"success":true}, sometimes with httpd_restart, and that confirms the delete.
96
+ func decodeSuccess(b []byte) (json.RawMessage, error) {
82
97
  var r envelope
83
98
  d := json.NewDecoder(bytes.NewReader(b))
84
- if uniqueValue(d, false) != nil || hasDSMError(b) || json.Unmarshal(b, &r) != nil || !r.Success || len(r.Data) == 0 || string(r.Data) == "null" {
85
- return nil, errors.New("DSM refused")
99
+ if uniqueValue(d, false) != nil || hasDSMError(b) || json.Unmarshal(b, &r) != nil || !r.Success {
100
+ var refused struct {
101
+ Error struct {
102
+ Code int `json:"code"`
103
+ } `json:"error"`
104
+ }
105
+ json.Unmarshal(b, &refused)
106
+ return nil, &dsmError{Code: refused.Error.Code}
86
107
  }
87
108
  return r.Data, nil
88
109
  }
@@ -117,6 +138,22 @@ func (d *liveDSM) Verify(ctx context.Context, cookie, token string) (string, err
117
138
  return out.Username, nil
118
139
  }
119
140
  func (d *liveDSM) call(ctx context.Context, api, method, version, runner string, params map[string]any) (json.RawMessage, error) {
141
+ b, err := d.exec(ctx, api, method, version, runner, params)
142
+ if err != nil {
143
+ return nil, err
144
+ }
145
+ return decodeEnvelope(b)
146
+ }
147
+
148
+ // mutate runs a call whose success carries no data, such as a scheduler delete.
149
+ func (d *liveDSM) mutate(ctx context.Context, api, method, version string, params map[string]any) error {
150
+ b, err := d.exec(ctx, api, method, version, "", params)
151
+ if err == nil {
152
+ _, err = decodeSuccess(b)
153
+ }
154
+ return err
155
+ }
156
+ func (d *liveDSM) exec(ctx context.Context, api, method, version, runner string, params map[string]any) ([]byte, error) {
120
157
  args := []string{"--exec", "api=" + api, "method=" + method, "version=" + version}
121
158
  if runner != "" {
122
159
  args = append(args, "runner="+runner)
@@ -140,7 +177,7 @@ func (d *liveDSM) call(ctx context.Context, api, method, version, runner string,
140
177
  }
141
178
  return nil, errors.New("DSM command")
142
179
  }
143
- return decodeEnvelope(b)
180
+ return b, nil
144
181
  }
145
182
  func (d *liveDSM) Account(ctx context.Context, name string) (Subject, error) {
146
183
  s, err := d.Accounts.Lookup(name)
@@ -1,3 +1,5 @@
1
1
  module synopkgland/executor
2
2
 
3
3
  go 1.23
4
+
5
+ toolchain go1.26.9