@synopackageland/cli 0.9.0 → 0.9.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/CHANGELOG.md +174 -1
- package/README.md +1 -1
- package/dist/add.js +12 -0
- package/dist/build-spk/dsm-version.d.ts +0 -2
- package/dist/build-spk/dsm-version.js +0 -6
- package/dist/build-spk/generator.js +4 -23
- package/dist/build-spk/resource-worker.js +0 -3
- package/dist/build-spk/uninstall-wizard.d.ts +8 -3
- package/dist/build-spk/uninstall-wizard.js +4 -2
- package/dist/build-spk/wizard-messages.d.ts +33 -0
- package/dist/build-spk/wizard-messages.js +33 -0
- package/dist/cli.js +7 -6
- package/dist/contract/validate.js +4 -21
- package/dist/error-registry.js +11 -12
- package/dist/errors.d.ts +2 -0
- package/dist/errors.js +5 -0
- package/dist/host/dsm-client.js +51 -30
- package/dist/host/types.d.ts +5 -0
- package/dist/images/pin.js +0 -1
- package/dist/info.js +14 -1
- package/dist/init.js +1 -2
- package/dist/portal/alias.d.ts +6 -4
- package/dist/portal/alias.js +6 -11
- package/dist/portal/package.d.ts +0 -3
- package/dist/portal/package.js +1 -7
- package/dist/runtime/executor-digests.json +33 -0
- package/dist/runtime/uninstall-cleanup.d.ts +7 -1
- package/dist/runtime/uninstall-cleanup.js +7 -1
- package/dist/standalone/dsm-window.js +8 -11
- package/dist/standalone/inspect-spk.js +11 -10
- package/dist/standalone/manifest.js +12 -3
- package/dist/standalone/root-window.d.ts +10 -3
- package/dist/standalone/root-window.js +81 -41
- package/dist/standalone/spk.js +3 -2
- package/dist/validate.js +2 -1
- package/package.json +3 -3
- package/schema/synology-app.schema.json +1 -10
- package/skills/SKILL.md +1 -1
- package/skills/reference/create.md +7 -2
- package/skills/reference/deploy.md +1 -1
- package/skills/reference/identity.md +1 -1
- package/skills/reference/portal.md +7 -3
|
@@ -2,7 +2,14 @@ export { type ExecutorTask as RootTask } from '../runtime/executor-tasks.js';
|
|
|
2
2
|
/** Exact readback is authoritative. PasswordConfirm tokens are reused immediately,
|
|
3
3
|
* across both Root APIs, and never persisted (DSM validity is about 300 seconds). */
|
|
4
4
|
export declare const ROOT_RUNTIME: string;
|
|
5
|
-
|
|
6
|
-
/**
|
|
7
|
-
|
|
5
|
+
type PanelLocale = "en" | "zh-TW" | "zh-CN" | "ja" | "ko" | "de" | "fr" | "es" | "it";
|
|
6
|
+
/** The plain-UI window panel's text; a state's reason stays the runtime's English detail. */
|
|
7
|
+
export declare const ROOT_PANEL_MESSAGES: Record<string, Record<PanelLocale, string>>;
|
|
8
|
+
/** ROOT_PANEL_MESSAGES as createI18n catalogs, one per locale. */
|
|
9
|
+
export declare function rootPanelCatalogs(): Record<string, Record<string, string>>;
|
|
10
|
+
/** The plain-UI approval panel: it asks for the password itself and passes it
|
|
11
|
+
* to `access.authorize`, the same PasswordConfirm path as ExecutorSettings. */
|
|
12
|
+
export declare const ROOT_PANEL = "function mountRootPanel(host, access, t, admin) {\n if (!admin) return;\n function el(tag, className) { var e = document.createElement(tag); if (className) e.className = className; return e; }\n var panel = el(\"div\", \"dsmapp-rootpanel\"), title = el(\"h2\"), status = el(\"p\"), reason = el(\"p\", \"dsmapp-rootpanel-hint\");\n var form = el(\"form\"), label = el(\"label\"), password = el(\"input\"), actions = el(\"div\", \"dsmapp-rootpanel-actions\");\n var approve = el(\"button\", \"dsmapp-rootpanel-primary\"), check = el(\"button\");\n title.textContent = t(\"title\"); label.textContent = t(\"password\");\n password.type = \"password\"; password.autocomplete = \"current-password\";\n approve.type = \"submit\"; approve.textContent = t(\"approve\");\n check.type = \"button\"; check.textContent = t(\"check\");\n label.appendChild(password); actions.appendChild(approve); actions.appendChild(check);\n form.appendChild(label); form.appendChild(actions);\n panel.appendChild(title); panel.appendChild(status); panel.appendChild(reason); panel.appendChild(form);\n host.appendChild(panel);\n function show(result) {\n status.textContent = t(\"state.\" + result.state);\n reason.textContent = result.reason || \"\"; reason.hidden = !result.reason;\n label.hidden = approve.hidden = result.state !== \"needs-admin-approval\" && result.retry !== true;\n }\n function run(fn) { approve.disabled = check.disabled = true; return fn().then(show, function(e) { show({state: \"blocked\", reason: e.message || e.code}); }).then(function() { approve.disabled = check.disabled = false; }); }\n access.subscribe(show);\n form.onsubmit = function(event) {\n if (event) event.preventDefault();\n var entered = password.value; password.value = \"\";\n if (!entered) return Promise.resolve();\n return run(function() { return access.authorize(entered); });\n };\n check.onclick = function() { return run(function() { return access.status(); }); };\n 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; }); });\n}";
|
|
13
|
+
/** Administrator password confirmation in the window's own session, for the
|
|
14
|
+
* password an approval panel collected. */
|
|
8
15
|
export declare const PASSWORD_CONFIRM: string;
|
|
@@ -155,22 +155,34 @@ export const ROOT_RUNTIME = `function createRootAccess(options) {
|
|
|
155
155
|
if (current.ref) throw fail("executor_selection_cleanup_conflict");
|
|
156
156
|
});
|
|
157
157
|
}
|
|
158
|
+
// The task waits up to 35 s for the executor, whose account, AppPriv and
|
|
159
|
+
// share lookups took 16-20 s on a measured NAS: read back for 40 s,
|
|
160
|
+
// backing off from 500 ms to 2 s like health.
|
|
161
|
+
function readSelection(budget, delay) {
|
|
162
|
+
delay = Math.min(delay || 500, 2000);
|
|
163
|
+
verified.state = "ready";
|
|
164
|
+
return health(verified, 0).then(function (current) {
|
|
165
|
+
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;
|
|
166
|
+
if (budget >= delay && options.wait) return options.wait(delay).then(function () { return readSelection(budget - delay, delay * 2); });
|
|
167
|
+
throw fail("executor_selection_unconfirmed");
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
// Right after a reinstall the boot task restarts the executor, so a run can
|
|
171
|
+
// miss it. Past the readback window that run has ended: run the pinned task
|
|
172
|
+
// once more. Its expected generation makes it stale if the first one landed.
|
|
173
|
+
function runAndRead(ref, runs) {
|
|
174
|
+
return send(taskAPI, "run", 2, {tasks: [ref]}).then(function () { return readSelection(40000); }).catch(function (e) {
|
|
175
|
+
if (runs > 1 && e && e.code === "executor_selection_unconfirmed") return runAndRead(ref, runs - 1);
|
|
176
|
+
throw e;
|
|
177
|
+
});
|
|
178
|
+
}
|
|
158
179
|
return find(task).then(function (current) {
|
|
159
180
|
if (current.state === "blocked" || current.state === "unsupported") throw fail("executor_selection_conflict");
|
|
160
181
|
var removed = current.ref ? remove(task, current.ref) : Promise.resolve();
|
|
161
182
|
return removed.then(function () { return create(false, definition(task), confirmation); });
|
|
162
183
|
}).then(function () { return find(task); }).then(function (current) {
|
|
163
184
|
if (current.state !== "ready") throw fail("executor_selection_readback_mismatch");
|
|
164
|
-
return
|
|
165
|
-
}).then(function () {
|
|
166
|
-
function readSelection(attempts) {
|
|
167
|
-
return health(verified, 0).then(function (current) {
|
|
168
|
-
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;
|
|
169
|
-
if (attempts > 1 && options.wait) return options.wait(500).then(function () { verified.state = "ready"; return readSelection(attempts - 1); });
|
|
170
|
-
throw fail("executor_selection_unconfirmed");
|
|
171
|
-
});
|
|
172
|
-
}
|
|
173
|
-
return readSelection(40);
|
|
185
|
+
return runAndRead(current.ref, 2);
|
|
174
186
|
}).then(function (result) { return removeExact().then(function () { return result; }); }, function (error) {
|
|
175
187
|
return removeExact().then(function () { throw error; });
|
|
176
188
|
});
|
|
@@ -188,6 +200,9 @@ export const ROOT_RUNTIME = `function createRootAccess(options) {
|
|
|
188
200
|
verified.executor = status; return verified;
|
|
189
201
|
}, function () { return null; });
|
|
190
202
|
}
|
|
203
|
+
// A started executor answered only after its own DSM lookups, which took
|
|
204
|
+
// 16-20 s on a measured NAS; giving up withdraws a fresh approval.
|
|
205
|
+
var startBudget = 30000;
|
|
191
206
|
// budget: how long to keep asking after a start (ms). The wait doubles from
|
|
192
207
|
// 500 ms to 2 s so an open window does not poll the executor twice a second.
|
|
193
208
|
function health(verified, budget, delay) {
|
|
@@ -211,7 +226,7 @@ export const ROOT_RUNTIME = `function createRootAccess(options) {
|
|
|
211
226
|
return send(eventAPI, "run", 1, verified.tasks.boot.ref);
|
|
212
227
|
}).then(function () {
|
|
213
228
|
// run can succeed for a nonexistent task. Read both definitions again.
|
|
214
|
-
return read().then(function (after) { return health(after,
|
|
229
|
+
return read().then(function (after) { return health(after, startBudget); });
|
|
215
230
|
}).catch(function (e) { verified.state = "blocked"; verified.reason = reason(e); return verified; });
|
|
216
231
|
}
|
|
217
232
|
// The window panel and the app's own settings share this access; each
|
|
@@ -230,26 +245,67 @@ export const ROOT_RUNTIME = `function createRootAccess(options) {
|
|
|
230
245
|
if (verified.state !== "ready") throw fail("executor_not_ready");
|
|
231
246
|
return options.call("executor.control", {action: action}).then(function () {
|
|
232
247
|
return send(taskAPI, "run", 2, {tasks: [verified.tasks.control.ref]});
|
|
233
|
-
}).then(read).then(function (after) { return action === "start" ? health(after,
|
|
248
|
+
}).then(read).then(function (after) { return action === "start" ? health(after, startBudget) : after; });
|
|
234
249
|
}).then(publish);
|
|
235
250
|
},
|
|
236
251
|
subscribe: function (listener) { listeners.push(listener); }
|
|
237
252
|
};
|
|
238
253
|
}`;
|
|
239
|
-
|
|
254
|
+
/** The plain-UI window panel's text; a state's reason stays the runtime's English detail. */
|
|
255
|
+
export const ROOT_PANEL_MESSAGES = {
|
|
256
|
+
title: { en: "Background access", "zh-TW": "背景存取", "zh-CN": "后台访问", ja: "バックグラウンドアクセス", ko: "백그라운드 액세스", de: "Hintergrundzugriff", fr: "Accès en arrière-plan", es: "Acceso en segundo plano", it: "Accesso in background" },
|
|
257
|
+
password: { en: "DSM administrator password", "zh-TW": "DSM 管理員密碼", "zh-CN": "DSM 管理员密码", ja: "DSM 管理者パスワード", ko: "DSM 관리자 비밀번호", de: "DSM-Administratorkennwort", fr: "Mot de passe administrateur DSM", es: "Contraseña de administrador de DSM", it: "Password amministratore DSM" },
|
|
258
|
+
approve: { en: "Approve", "zh-TW": "核准", "zh-CN": "批准", ja: "承認", ko: "승인", de: "Genehmigen", fr: "Approuver", es: "Aprobar", it: "Approva" },
|
|
259
|
+
check: { en: "Check status", "zh-TW": "檢查狀態", "zh-CN": "检查状态", ja: "状態を確認", ko: "상태 확인", de: "Status prüfen", fr: "Vérifier l'état", es: "Comprobar estado", it: "Verifica stato" },
|
|
260
|
+
"state.ready": { en: "Approved and running.", "zh-TW": "已核准,正在執行。", "zh-CN": "已批准,正在运行。", ja: "承認済みで、実行中です。", ko: "승인되었으며 실행 중입니다.", de: "Genehmigt und aktiv.", fr: "Approuvé et actif.", es: "Aprobado y en ejecución.", it: "Approvato e in esecuzione." },
|
|
261
|
+
"state.needs-admin-approval": { en: "Needs administrator approval.", "zh-TW": "需要管理員核准。", "zh-CN": "需要管理员批准。", ja: "管理者の承認が必要です。", ko: "관리자 승인이 필요합니다.", de: "Genehmigung durch einen Administrator erforderlich.", fr: "Approbation d'un administrateur requise.", es: "Requiere la aprobación de un administrador.", it: "Richiede l'approvazione di un amministratore." },
|
|
262
|
+
"state.blocked": { en: "Not available.", "zh-TW": "目前無法使用。", "zh-CN": "目前无法使用。", ja: "現在は利用できません。", ko: "현재 사용할 수 없습니다.", de: "Derzeit nicht verfügbar.", fr: "Indisponible pour le moment.", es: "No disponible por ahora.", it: "Non disponibile al momento." },
|
|
263
|
+
"state.unsupported": { en: "This DSM does not support background access.", "zh-TW": "這台 DSM 不支援背景存取。", "zh-CN": "此 DSM 不支持后台访问。", ja: "この DSM はバックグラウンドアクセスに対応していません。", ko: "이 DSM은 백그라운드 액세스를 지원하지 않습니다.", de: "Dieses DSM unterstützt keinen Hintergrundzugriff.", fr: "Ce DSM ne prend pas en charge l'accès en arrière-plan.", es: "Este DSM no admite el acceso en segundo plano.", it: "Questo DSM non supporta l'accesso in background." },
|
|
264
|
+
"state.cancelled": { en: "Approval was cancelled.", "zh-TW": "已取消核准。", "zh-CN": "已取消批准。", ja: "承認はキャンセルされました。", ko: "승인이 취소되었습니다.", de: "Genehmigung abgebrochen.", fr: "Approbation annulée.", es: "Aprobación cancelada.", it: "Approvazione annullata." },
|
|
265
|
+
};
|
|
266
|
+
/** ROOT_PANEL_MESSAGES as createI18n catalogs, one per locale. */
|
|
267
|
+
export function rootPanelCatalogs() {
|
|
268
|
+
const out = {};
|
|
269
|
+
for (const [key, texts] of Object.entries(ROOT_PANEL_MESSAGES)) {
|
|
270
|
+
for (const [locale, text] of Object.entries(texts))
|
|
271
|
+
(out[locale] ??= {})[key] = text;
|
|
272
|
+
}
|
|
273
|
+
return out;
|
|
274
|
+
}
|
|
275
|
+
/** The plain-UI approval panel: it asks for the password itself and passes it
|
|
276
|
+
* to `access.authorize`, the same PasswordConfirm path as ExecutorSettings. */
|
|
277
|
+
export const ROOT_PANEL = `function mountRootPanel(host, access, t, admin) {
|
|
240
278
|
if (!admin) return;
|
|
241
|
-
var
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
279
|
+
function el(tag, className) { var e = document.createElement(tag); if (className) e.className = className; return e; }
|
|
280
|
+
var panel = el("div", "dsmapp-rootpanel"), title = el("h2"), status = el("p"), reason = el("p", "dsmapp-rootpanel-hint");
|
|
281
|
+
var form = el("form"), label = el("label"), password = el("input"), actions = el("div", "dsmapp-rootpanel-actions");
|
|
282
|
+
var approve = el("button", "dsmapp-rootpanel-primary"), check = el("button");
|
|
283
|
+
title.textContent = t("title"); label.textContent = t("password");
|
|
284
|
+
password.type = "password"; password.autocomplete = "current-password";
|
|
285
|
+
approve.type = "submit"; approve.textContent = t("approve");
|
|
286
|
+
check.type = "button"; check.textContent = t("check");
|
|
287
|
+
label.appendChild(password); actions.appendChild(approve); actions.appendChild(check);
|
|
288
|
+
form.appendChild(label); form.appendChild(actions);
|
|
289
|
+
panel.appendChild(title); panel.appendChild(status); panel.appendChild(reason); panel.appendChild(form);
|
|
290
|
+
host.appendChild(panel);
|
|
291
|
+
function show(result) {
|
|
292
|
+
status.textContent = t("state." + result.state);
|
|
293
|
+
reason.textContent = result.reason || ""; reason.hidden = !result.reason;
|
|
294
|
+
label.hidden = approve.hidden = result.state !== "needs-admin-approval" && result.retry !== true;
|
|
295
|
+
}
|
|
296
|
+
function run(fn) { approve.disabled = check.disabled = true; return fn().then(show, function(e) { show({state: "blocked", reason: e.message || e.code}); }).then(function() { approve.disabled = check.disabled = false; }); }
|
|
246
297
|
access.subscribe(show);
|
|
247
|
-
|
|
248
|
-
|
|
298
|
+
form.onsubmit = function(event) {
|
|
299
|
+
if (event) event.preventDefault();
|
|
300
|
+
var entered = password.value; password.value = "";
|
|
301
|
+
if (!entered) return Promise.resolve();
|
|
302
|
+
return run(function() { return access.authorize(entered); });
|
|
303
|
+
};
|
|
304
|
+
check.onclick = function() { return run(function() { return access.status(); }); };
|
|
249
305
|
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; }); });
|
|
250
306
|
}`;
|
|
251
|
-
/** Administrator password confirmation in the window's own session
|
|
252
|
-
* password
|
|
307
|
+
/** Administrator password confirmation in the window's own session, for the
|
|
308
|
+
* password an approval panel collected. */
|
|
253
309
|
export const PASSWORD_CONFIRM = `function createPasswordConfirm(win) {
|
|
254
310
|
var versionUnsupported = ${versionUnsupported.toString()};
|
|
255
311
|
function send(password, version) {
|
|
@@ -270,24 +326,8 @@ export const PASSWORD_CONFIRM = `function createPasswordConfirm(win) {
|
|
|
270
326
|
return send(password, 1);
|
|
271
327
|
});
|
|
272
328
|
}
|
|
273
|
-
return function (
|
|
274
|
-
if (typeof
|
|
275
|
-
return
|
|
276
|
-
var form = document.createElement("form"), label = document.createElement("label"), input = document.createElement("input");
|
|
277
|
-
var submit = document.createElement("button"), cancel = document.createElement("button");
|
|
278
|
-
label.textContent = "Confirm your DSM administrator password";
|
|
279
|
-
input.type = "password"; input.autocomplete = "current-password"; input.required = true;
|
|
280
|
-
label.appendChild(input); form.appendChild(label);
|
|
281
|
-
submit.type = "submit"; submit.textContent = "Approve"; form.appendChild(submit);
|
|
282
|
-
cancel.type = "button"; cancel.textContent = "Cancel"; form.appendChild(cancel);
|
|
283
|
-
win.body.dom.appendChild(form); input.focus();
|
|
284
|
-
function close() { input.value = ""; if (form.parentNode) form.parentNode.removeChild(form); }
|
|
285
|
-
cancel.onclick = function () { close(); resolve({confirmed: false}); };
|
|
286
|
-
form.onsubmit = function (event) {
|
|
287
|
-
event.preventDefault(); submit.disabled = cancel.disabled = true;
|
|
288
|
-
var password = input.value; input.value = "";
|
|
289
|
-
auth(password).then(function (result) { close(); resolve(result); }, function (error) { close(); reject(error); });
|
|
290
|
-
};
|
|
291
|
-
});
|
|
329
|
+
return function (password) {
|
|
330
|
+
if (typeof password !== "string" || !password) return Promise.reject({code: "password_required"});
|
|
331
|
+
return auth(password);
|
|
292
332
|
};
|
|
293
333
|
}`;
|
package/dist/standalone/spk.js
CHANGED
|
@@ -12,6 +12,7 @@ import { writeWizardUiFiles } from "../build-spk/wizard-i18n.js";
|
|
|
12
12
|
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
|
+
import { codedMessage } from "../errors.js";
|
|
15
16
|
import { DSM_LANGS, LOCALES } from "./i18n.js";
|
|
16
17
|
import { appTitle, CGI_PAYLOAD_PATH, renderUiConfig, renderWindowScript, windowScriptName } from "./dsm-window.js";
|
|
17
18
|
import { writeUiTexts } from "../build-spk/ui-texts.js";
|
|
@@ -40,7 +41,7 @@ export const LIFECYCLE_SCRIPTS = [
|
|
|
40
41
|
export class StandaloneBuildError extends Error {
|
|
41
42
|
code;
|
|
42
43
|
constructor(code, message) {
|
|
43
|
-
super(
|
|
44
|
+
super(codedMessage(code, message));
|
|
44
45
|
this.code = code;
|
|
45
46
|
}
|
|
46
47
|
}
|
|
@@ -130,7 +131,7 @@ export async function assembleStandaloneSpk(options) {
|
|
|
130
131
|
...install.static,
|
|
131
132
|
...install.dynamic,
|
|
132
133
|
...(folder ? renderSharedFolderUpgradeWizardFiles(manifest.package, folder) : {}),
|
|
133
|
-
...renderUninstallWizardFiles(manifest.package, "native", { executor: Boolean(options.executor) }),
|
|
134
|
+
...renderUninstallWizardFiles(manifest.package, "native", { executor: Boolean(options.executor), portal: Boolean(portal) }),
|
|
134
135
|
});
|
|
135
136
|
writePackageCenterIcons(staging, icons);
|
|
136
137
|
mkdirSync(dirname(options.outputPath), { recursive: true });
|
package/dist/validate.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { existsSync } from "node:fs";
|
|
2
2
|
import { join } from "node:path";
|
|
3
3
|
import { contractSpan, makeError } from "./errors.js";
|
|
4
|
+
import { getErrorCode } from "./error-registry.js";
|
|
4
5
|
import { collectInstallParameters, getValuePosition, loadContract } from "./contract/load.js";
|
|
5
6
|
import { validateContract } from "./contract/validate.js";
|
|
6
7
|
import { findListingUnsupportedInstallTypes } from "./contract/unsupported-types.js";
|
|
@@ -30,7 +31,7 @@ export function validateProject(project, options = {}) {
|
|
|
30
31
|
const loaded = isNativeLayout(project.root) ? loadNativeLayout(project.root) : loadStandaloneManifest(project.root);
|
|
31
32
|
return loaded.ok ? { ok: true, errors: [] } : { ok: false, errors: loaded.errors.map(error => ({
|
|
32
33
|
...error, span: { file: DSM_PACKAGE_FILENAME, line: 1, column: 1 },
|
|
33
|
-
suggestion: "請修正 dsm-package.yaml 的 schema 2 欄位與能力宣告。",
|
|
34
|
+
suggestion: getErrorCode(error.code)?.suggestion ?? "請修正 dsm-package.yaml 的 schema 2 欄位與能力宣告。",
|
|
34
35
|
})) };
|
|
35
36
|
}
|
|
36
37
|
const parsed = loadContract(project.contractPath);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@synopackageland/cli",
|
|
3
|
-
"version": "0.9.
|
|
3
|
+
"version": "0.9.2",
|
|
4
4
|
"description": "Syno Package Land Developer Kit CLI",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -19,12 +19,12 @@
|
|
|
19
19
|
"scripts": {
|
|
20
20
|
"build": "rm -rf dist && tsc",
|
|
21
21
|
"test": "npm run build && node --import tsx --test test/**/*.test.ts",
|
|
22
|
-
"test:scripts": "node --test scripts/test/release.test.mjs scripts/test/pack-docker.test.mjs scripts/test/npm-pack.test.mjs scripts/test/executor-bump.test.mjs",
|
|
22
|
+
"test:scripts": "node --test scripts/test/release.test.mjs scripts/test/pack-docker.test.mjs scripts/test/npm-pack.test.mjs scripts/test/executor-bump.test.mjs scripts/test/smoke-exports.test.mjs scripts/test/executor-digests.test.mjs",
|
|
23
23
|
"gates": "bash scripts/ci/gate.sh npm && bash scripts/ci/gate.sh native-go && bash scripts/ci/gate.sh daemon-gateway",
|
|
24
24
|
"prepublishOnly": "npm run build"
|
|
25
25
|
},
|
|
26
26
|
"dependencies": {
|
|
27
|
-
"@synopackageland/templates": "0.9.
|
|
27
|
+
"@synopackageland/templates": "0.9.2",
|
|
28
28
|
"ajv": "^8.17.1",
|
|
29
29
|
"commander": "^13.1.0",
|
|
30
30
|
"tar": "^7.4.3",
|
|
@@ -151,16 +151,7 @@
|
|
|
151
151
|
"description": "DSM group names allowed in; only with access: dsm-groups."
|
|
152
152
|
},
|
|
153
153
|
"portal": {
|
|
154
|
-
"
|
|
155
|
-
"properties": {
|
|
156
|
-
"alias": {
|
|
157
|
-
"type": "string",
|
|
158
|
-
"pattern": "^[a-z0-9](?:[a-z0-9-]{0,62}[a-z0-9])?$",
|
|
159
|
-
"description": "Default DSM Application Portal alias (https://<nas>/<alias>/). Omit to use the package name. An administrator still applies it."
|
|
160
|
-
}
|
|
161
|
-
},
|
|
162
|
-
"additionalProperties": false,
|
|
163
|
-
"description": "Register the App in DSM Control Panel > Login Portal > Applications. Needs launch: dsm-window behind the DSM gateway (access other than public) and os_min_ver 7.3.2-86009 or later. Registration alone exposes nothing."
|
|
154
|
+
"description": "Not available: a Compose App has no executor, and a Login Portal alias needs the root executor to stop nginx serving it after uninstall. validate refuses it with CONTRACT_PORTAL_REQUIRES_EXECUTOR; an administrator can set up a reverse proxy in DSM instead."
|
|
164
155
|
}
|
|
165
156
|
},
|
|
166
157
|
"additionalProperties": true
|
package/skills/SKILL.md
CHANGED
|
@@ -13,7 +13,7 @@ Never install, uninstall, publish or modify NAS data without task authorization.
|
|
|
13
13
|
|
|
14
14
|
Native projects use schema 2 capabilities and `--with files,jobs,login,root-ops,delegate`; use `add` for existing projects. Prefer `check --json`, `build --json`, `inspect <spk> --json`, `deploy --dry-run --json`, and `status --host <profile> --json` for structured results. Read the conflict policy in reference/create.md before adding overlays.
|
|
15
15
|
|
|
16
|
-
Read reference/portal.md before declaring or changing a DSM Application Portal alias (`add portal`); DSM owns the saved setting. Read reference/files.md and reference/identity.md for capability boundaries. RootOps and delegate share one-time protected executor boot/control setup with digest approval. Existing native products can explicitly use the separate authorization-sdk build-time adapter; do not mix its tasks with generated executor capabilities. App sessions alone do not grant NAS file authority.
|
|
16
|
+
Read reference/portal.md before declaring or changing a DSM Application Portal alias (`add portal`); DSM owns the saved setting, and only an App with rootOps or delegate may declare one. Read reference/files.md and reference/identity.md for capability boundaries. RootOps and delegate share one-time protected executor boot/control setup with digest approval. Existing native products can explicitly use the separate authorization-sdk build-time adapter; do not mix its tasks with generated executor capabilities. App sessions alone do not grant NAS file authority.
|
|
17
17
|
|
|
18
18
|
Read [reference/ui-vue.md](reference/ui-vue.md) when editing Vue components in the native-go Vue template; use the installed SDK contracts, keep DSM embedding boundaries, and verify the actual app.
|
|
19
19
|
|
|
@@ -16,7 +16,7 @@ synopkgland init -t compose --from ./compose.yaml upstream-app
|
|
|
16
16
|
|
|
17
17
|
## 容器存取控制
|
|
18
18
|
|
|
19
|
-
有 adminport 的容器 App 預設經 DSM 驗證 gateway(`integration.web.access`:dsm-users 預設、dsm-admins、dsm-groups+groups、public)。Compose published port 必須綁 loopback:`127.0.0.1:<adminport>:<port>`,否則 check 失敗(`COMPOSE_GATEWAY_PORT_EXPOSED`)。gateway 是編譯的 Go,建置需要 Go 與 `--arch`。`public` 才保留匿名 Web Station portal,inspect 會以 `CONTAINER_ANONYMOUS_PORTAL` 提醒。
|
|
19
|
+
有 adminport 的容器 App 預設經 DSM 驗證 gateway(`integration.web.access`:dsm-users 預設、dsm-admins、dsm-groups+groups、public)。Compose published port 必須綁 loopback:`127.0.0.1:<adminport>:<port>`,否則 check 失敗(`COMPOSE_GATEWAY_PORT_EXPOSED`)。gateway 是編譯的 Go,建置需要 Go 與 `--arch`。`public` 才保留匿名 Web Station portal,inspect 會以 `CONTAINER_ANONYMOUS_PORTAL` 提醒。Compose App 沒有 executor,不能宣告 `integration.web.portal`(`CONTRACT_PORTAL_REQUIRES_EXECUTOR`)。
|
|
20
20
|
|
|
21
21
|
## 建置與檢查
|
|
22
22
|
|
|
@@ -31,7 +31,12 @@ native-go 與 binary 使用 `dsm-package.yaml` 的 `schema: 2`,backend 寫 `ki
|
|
|
31
31
|
既有 native-go 專案用 `synopkgland add files jobs login`;已存在能力回報 no-op。overlay 檔案已有不同內容或 stub 被改動時,add 拒絕並列出衝突;不修改業務 routes 或 App.vue。
|
|
32
32
|
check、inspect、build、deploy、status 支援 `--json`,stdout 只有單一 JSON 物件,錯誤包含 code、message、hint;進度在 stderr。
|
|
33
33
|
|
|
34
|
-
rootOps 與 delegate 已共用管理員 PasswordConfirm 核准的 protected executor、boot/control tasks;精確 task readback 與 digest health 一致才 ready,
|
|
34
|
+
rootOps 與 delegate 已共用管理員 PasswordConfirm 核准的 protected executor、boot/control tasks;精確 task readback 與 digest health 一致才 ready;policy(operations、scope、root 腳本)變更須重核准,policy 不變的 executor 更新會自動 repin,install-selected 的 scope 需管理員確認一次。Vue App 只在 SDK 的 ExecutorSettings 核准;plain UI 由 DSM 視窗面板核准,兩者都在面板內輸入密碼並走 PasswordConfirm。native 產品另可明確 opt-in authorization-sdk adapter,不與 generated executor tasks 混用。SDK off stub 與 routes_test.go 的預設 policy scaffold 只有內容仍等於 bundled base 時才會替換;App 自訂版本拒絕。
|
|
35
|
+
|
|
36
|
+
## 兩種層級
|
|
37
|
+
|
|
38
|
+
- 無升權 App(沒有 rootOps、delegate;含所有 Compose App):安裝、執行、解除安裝都只用套件帳號,任何階段都不用 root。不能宣告 portal(native `STANDALONE_PORTAL_REQUIRES_EXECUTOR`、Compose `CONTRACT_PORTAL_REQUIRES_EXECUTOR`);需要登入入口別名時由管理員在 DSM 手動設定反向代理。解除安裝後 DSM 保留的「控制台」>「應用程式權限」規則由管理員手動移除,精靈與 cleanup 清單會說明。
|
|
39
|
+
- executor App(rootOps 或 delegate):解除安裝時 root purge 自動刪除 `SYNO.SDS.<package>.Instance` 的應用程式權限規則,宣告 portal 時在 DSM 移除別名後 reload nginx。精靈說明自動清除;cleanup 清單只在自動清除失敗時(系統日誌 `synopkgland-executor`)才需要管理員處理。
|
|
35
40
|
|
|
36
41
|
## Permanent destination integration
|
|
37
42
|
|
|
@@ -7,6 +7,6 @@
|
|
|
7
7
|
|
|
8
8
|
Deployment uploads directly to DSM. It does not publish artifacts. Container install answers use --share, --run-as and --param; omitted upgrade answers are retained by package preinst, which also rejects missing newly required values.
|
|
9
9
|
|
|
10
|
-
For native-go root-ops, approve fixed operations in DSM after installation. Updates that change the task text require fresh approval.
|
|
10
|
+
For native-go root-ops, approve fixed operations in DSM after installation. Updates that change the task text require fresh approval. Uninstall removes the executor tasks, the App's Application Privileges rules and a stale portal alias itself. Never delete installed packages or data as part of a code cleanup.
|
|
11
11
|
|
|
12
12
|
5. After installation run `synopkgland status --host <profile> --json`; inspect every capability state and Open URL. Installation is not readiness. Delegate is implemented and shipped (default `files.moveFromStaging`); full device lifecycle acceptance is still pending for the shared delegate/rootOps executor: install → setup → reboot → upgrade → revoke → uninstall. Delete App Data is not evidence of complete cleanup; verify retained directories/account artifacts.
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
Native-go validates the caller's DSM session per package. Business route permissions come from backend/internal/api/permissions.json; SDK-owned route policy is protected separately. Capabilities describe execution/resources and do not grant business route access. Never trust a request username as owner.
|
|
4
4
|
|
|
5
|
-
Entry: omit `entry` for legacy access; `entry: admin-only` admits administrators only and is the floor of every route: SDK routes a capability adds (files, jobs, portal, delegate, rootOps) refuse ordinary users with `admin_required`, the launcher is hidden, and an app route marked `authenticated` or `public` in permissions.json fails validate/build with `STANDALONE_ENTRY_WIDENED` (only the public `identity: standalone` login routes stay public; they admit no one by themselves). The SPK records the mode in `sdk-package.json`; `inspect` prints it and flags `ENTRY_LAUNCHER_MISMATCH`; `entry: app-privilege` also admits accounts DSM Application Privileges allows for `SYNO.SDS.<package>.Instance` (native-go only, needs an `authenticated` route). DSM evaluates user/group/everyone rules, allow/deny precedence and IP ranges; one CGI check covers every route, failures refuse with `app_privilege_denied`, and decisions are cached at most 30 seconds. DSM's install default allows administrators only, so declaring or upgrading never opens the app to everyone. The delegate executor applies the same entry rule to every protected and producer move: once DSM no longer allows a user, new moves under that user's still-valid grant are refused (`APP_DENIED`, grant invalidated); files already saved stay. Guard global settings in shared routes with Go `api.RequireAdmin(caller)` (`admin_required`); the frontend `ctx.user?.isAdmin` only hides controls.
|
|
5
|
+
Entry: omit `entry` for legacy access; `entry: admin-only` admits administrators only and is the floor of every route: SDK routes a capability adds (files, jobs, portal, delegate, rootOps) refuse ordinary users with `admin_required`, the launcher is hidden, and an app route marked `authenticated` or `public` in permissions.json fails validate/build with `STANDALONE_ENTRY_WIDENED` (only the public `identity: standalone` login routes stay public; they admit no one by themselves). The SPK records the mode in `sdk-package.json`; `inspect` prints it and flags `ENTRY_LAUNCHER_MISMATCH`; `entry: app-privilege` also admits accounts DSM Application Privileges allows for `SYNO.SDS.<package>.Instance` (native-go only, needs an `authenticated` route). DSM evaluates user/group/everyone rules, allow/deny precedence and IP ranges; one CGI check covers every route, failures refuse with `app_privilege_denied`, and decisions are cached at most 30 seconds. DSM's install default allows administrators only, so declaring or upgrading never opens the app to everyone. DSM keeps those rules after uninstall and applies them again to a later install under the same name: an App with the executor (rootOps or delegate) removes them automatically at uninstall (failures go to the system log entry `synopkgland-executor`); a no-privilege App never uses root, so an administrator removes them in Control Panel > Application Privileges. The delegate executor applies the same entry rule to every protected and producer move: once DSM no longer allows a user, new moves under that user's still-valid grant are refused (`APP_DENIED`, grant invalidated); files already saved stay. Guard global settings in shared routes with Go `api.RequireAdmin(caller)` (`admin_required`); the frontend `ctx.user?.isAdmin` only hides controls.
|
|
6
6
|
|
|
7
7
|
I capability: init --with login or add login enables Go-native standalone identity and Vue login. Same-host DSM cookies can be reused across ports after backend verification. Missing cookies use an HTTPS DSM account/password/OTP fallback; passwords and DSM credentials are not persisted. Plain UI is unsupported. App sessions last at most 15 minutes; sensitive operations revalidate live DSM cookies and local UID. Password-only sessions permit only basic app.status/greeting.get reads. Missing revalidation returns session_revalidation_required; files return dsm_session_required. Sign in to DSM on the same hostname. Web Station portal/proxy acceptance remains pending.
|
|
8
8
|
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
# DSM Application Portal
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Tier: only an App with the executor (`capabilities.rootOps` or `capabilities.delegate`) may declare a portal, because after uninstall only root can make nginx stop serving the alias. A no-privilege native App fails check/build with `STANDALONE_PORTAL_REQUIRES_EXECUTOR`, `add portal` refuses it unless the same call adds `root-ops` or `delegate`, and inspect flags such an SPK with `PORTAL_REQUIRES_EXECUTOR`. A Compose App has no executor: `integration.web.portal` always fails with `CONTRACT_PORTAL_REQUIRES_EXECUTOR`. Without the executor, an administrator can set up a reverse proxy in DSM instead.
|
|
4
|
+
|
|
5
|
+
Declare: native-go/binary with rootOps or delegate, `portal: {}` or `portal: {alias: my-app}` in dsm-package.yaml (`synopkgland add root-ops portal`, or `add portal` once the executor is declared). The alias defaults to the package name in lowercase. Aliases are 1–64 lowercase letters, digits or `-`, not starting or ending with `-`, and not a DSM path or Synology App alias (`webman`, `file`, …). The native-layout `backend: {kind: native}` owns its conf/scripts: the SDK does not register a portal for it.
|
|
4
6
|
|
|
5
7
|
Build: the SPK carries `etc/portal.json` (every field DSM's app-portal worker needs) and `etc/portal.mustache`, `conf/resource` registers them, and the install wizard adds a prefilled alias step. preinst validates the answer; postinst stores it as the package's own draft and default alias. Nothing is active until an administrator applies it. `inspect <spk> --json` reports `portal: {appId, alias}` and refuses any other portal content.
|
|
6
8
|
|
|
@@ -8,10 +10,12 @@ Activate: DSM owns the saved setting. Administrators use the app's portal panel
|
|
|
8
10
|
|
|
9
11
|
Read: ordinary users call the Go backend's `portal.status` (`authenticated`), which reads `/var/packages/<package>/etc/AppPortal.json` read-only, bounded (64 KiB, no symlinks, one JSON object, matching app id). Anything else is `unavailable` and the UI shows "ask an administrator". A binary CGI must implement the same `{state, alias}` answer itself or ordinary users never see the link.
|
|
10
12
|
|
|
11
|
-
DSM floor: os_min_ver must be 7.3.2-86009 or later. DSM 7.1.1-42962 refuses an unsigned SPK whose conf/resource carries web-config app-portal (Package Center error 320, "non-synology-signed package should not use internal worker"); 7.3.2-86009 and 7.4 install it; 7.2 and 7.3.0/7.3.1 are untested (unknown). validate/build fail with `STANDALONE_PORTAL_MIN_DSM
|
|
13
|
+
DSM floor: os_min_ver must be 7.3.2-86009 or later. DSM 7.1.1-42962 refuses an unsigned SPK whose conf/resource carries web-config app-portal (Package Center error 320, "non-synology-signed package should not use internal worker"); 7.3.2-86009 and 7.4 install it; 7.2 and 7.3.0/7.3.1 are untested (unknown). validate/build fail with `STANDALONE_PORTAL_MIN_DSM`, `--min-dsm` cannot go below it, `add portal` raises a lower os_min_ver, and inspect flags a lower INFO os_min_ver. Fix: raise os_min_ver or remove the portal.
|
|
12
14
|
|
|
13
15
|
Capability: beyond that floor, no version gate. The panel needs SYNO.API.Info to list `SYNO.Core.AppPortal` (v2) and the app's registration to exist; otherwise administrators get only the Control Panel shortcut.
|
|
14
16
|
|
|
15
17
|
UI: plain JS calls `ctx.portal && ctx.portal.mountPanel(element)` and disposes it on unmount; Vue renders `portal/PortalSettings.vue`. `ctx.portal.messages` carries the SDK text in all nine languages.
|
|
16
18
|
|
|
17
|
-
Never: write `AppPortal.json`, `@appconf/<package>` or `/usr/syno/etc/www/app.d`; add a backend route that changes the alias; relay QuickConnect responses or build QuickConnect links; reboot or change other packages' portal entries while testing. DSM keeps the alias across upgrades and removes the entry on uninstall.
|
|
19
|
+
Never: write `AppPortal.json`, `@appconf/<package>` or `/usr/syno/etc/www/app.d`; add a backend route that changes the alias; relay QuickConnect responses or build QuickConnect links; reboot or change other packages' portal entries while testing. DSM keeps the alias across upgrades and removes the entry on uninstall, but it does not reload nginx, and it keeps the App's Application Privileges rules.
|
|
20
|
+
|
|
21
|
+
Uninstall: the root executor's purge deletes the rules of `SYNO.SDS.<package>.Instance` through the Application Privileges API and, once DSM has removed the alias files, reloads nginx (`synosystemctl reload nginx`), so the alias stops at once. Failures are only logged to the system log entry `synopkgland-executor`; then an administrator saves an alias in Control Panel > Login Portal > Applications (which reloads nginx) or restarts the NAS, and removes the rules in Control Panel > Application Privileges. The uninstall wizard and the `status`/`inspect` cleanup list say this in that order: automatic first, manual only as the fallback.
|