@synopackageland/cli 0.9.4 → 0.9.6
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 +94 -0
- package/README.md +1 -1
- package/assets/licenses/MUSL-COPYRIGHT +193 -0
- package/assets/licenses/RUST-COPYRIGHT-library.html +5556 -0
- package/dist/add.js +29 -29
- package/dist/build-spk/dsm-version.d.ts +14 -4
- package/dist/build-spk/dsm-version.js +17 -5
- package/dist/build-spk/notices.d.ts +8 -1
- package/dist/build-spk/notices.js +13 -4
- package/dist/build-spk/uninstall-wizard.d.ts +11 -0
- package/dist/build-spk/uninstall-wizard.js +16 -3
- package/dist/build-spk/wizard-i18n.d.ts +1 -1
- package/dist/build-spk/wizard-i18n.js +1 -1
- package/dist/build-spk/wizard-messages.d.ts +44 -0
- package/dist/build-spk/wizard-messages.js +44 -0
- package/dist/capability-overlays.d.ts +10 -5
- package/dist/capability-overlays.js +32 -10
- package/dist/cli.js +5 -5
- package/dist/deploy/plan.d.ts +1 -1
- package/dist/deploy/plan.js +1 -1
- package/dist/error-registry.js +46 -4
- package/dist/info.js +4 -3
- package/dist/init.js +53 -28
- package/dist/portal/alias.d.ts +8 -0
- package/dist/portal/alias.js +16 -0
- package/dist/runtime/executor-build.d.ts +1 -1
- package/dist/runtime/executor-build.js +2 -1
- package/dist/runtime/executor-digests.json +22 -0
- package/dist/runtime/executor-policy.d.ts +15 -0
- package/dist/runtime/executor-policy.js +32 -6
- package/dist/runtime/sdk-source.d.ts +14 -2
- package/dist/runtime/sdk-source.js +39 -4
- package/dist/runtime/uninstall-cleanup.d.ts +5 -1
- package/dist/runtime/uninstall-cleanup.js +11 -1
- package/dist/standalone/api-permissions.d.ts +4 -2
- package/dist/standalone/api-permissions.js +6 -5
- package/dist/standalone/binary.d.ts +3 -2
- package/dist/standalone/binary.js +17 -5
- package/dist/standalone/build.js +16 -12
- package/dist/standalone/daemon.d.ts +6 -0
- package/dist/standalone/daemon.js +8 -4
- package/dist/standalone/dsm-window.js +16 -3
- package/dist/standalone/inspect-spk.d.ts +1 -1
- package/dist/standalone/inspect-spk.js +46 -13
- package/dist/standalone/lifecycle.js +38 -4
- package/dist/standalone/manifest.d.ts +12 -0
- package/dist/standalone/manifest.js +62 -20
- package/dist/standalone/native-layout.d.ts +4 -7
- package/dist/standalone/native-layout.js +41 -102
- package/dist/standalone/native-root-window.d.ts +2 -1
- package/dist/standalone/native-root-window.js +15 -5
- package/dist/standalone/portal-window.d.ts +4 -2
- package/dist/standalone/portal-window.js +54 -12
- package/dist/standalone/portal.d.ts +4 -1
- package/dist/standalone/portal.js +8 -2
- package/dist/standalone/root-ops.d.ts +2 -0
- package/dist/standalone/root-ops.js +2 -0
- package/dist/standalone/rust-build.d.ts +35 -0
- package/dist/standalone/rust-build.js +127 -0
- package/dist/standalone/sdk-check.d.ts +5 -3
- package/dist/standalone/sdk-check.js +21 -8
- package/dist/standalone/spk.js +15 -3
- package/dist/standalone/toolkit.d.ts +10 -0
- package/dist/standalone/toolkit.js +101 -0
- package/dist/standalone/uninstall-hook.d.ts +10 -0
- package/dist/standalone/uninstall-hook.js +71 -0
- package/dist/standalone/uninstall.d.ts +53 -0
- package/dist/standalone/uninstall.js +136 -0
- package/dist/types.d.ts +1 -1
- package/dist/yaml-edit.d.ts +6 -0
- package/dist/yaml-edit.js +31 -0
- package/package.json +2 -2
- package/skills/reference/portal.md +8 -4
|
@@ -7,14 +7,15 @@ import { isNativeArchFamily } from "../build-spk/dsm-arch.js";
|
|
|
7
7
|
import { checkGlossary, loadCatalogs, readInfoTranslations } from "./i18n.js";
|
|
8
8
|
import { loadRootOps, ROOT_OPS_DIR } from "./root-ops.js";
|
|
9
9
|
import { readDaemon } from "./daemon.js";
|
|
10
|
+
import { readUninstall } from "./uninstall.js";
|
|
10
11
|
import { readBackground } from "./background.js";
|
|
11
|
-
import { DEFAULT_MIN_DSM, isDsmAtLeast,
|
|
12
|
+
import { DEFAULT_MIN_DSM, isDsmAtLeast, PORTAL_EXECUTOR_MIN_DSM, portalByExecutor, portalMinDsmMessage } from "../build-spk/dsm-version.js";
|
|
12
13
|
import { readSharedFolder } from "./shared-folder.js";
|
|
13
|
-
import { floorAtAdministrators, permitsOrdinaryUsers, readApiPermissions } from "./api-permissions.js";
|
|
14
|
-
import { PORTAL_REQUIRES_EXECUTOR, readPortalDeclaration } from "../portal/alias.js";
|
|
14
|
+
import { API_PERMISSIONS_FILE, floorAtAdministrators, permitsOrdinaryUsers, readApiPermissions } from "./api-permissions.js";
|
|
15
|
+
import { PORTAL_REQUIRES_EXECUTOR, portalEntryProblem, readPortalDeclaration } from "../portal/alias.js";
|
|
15
16
|
import { renderPackageIcons } from "../build-spk/dsm-icons.js";
|
|
16
17
|
import { validateReproducible } from "../reproducible-config.js";
|
|
17
|
-
import {
|
|
18
|
+
import { appSdkVersion, SDK_CRATE, SDK_MODULE } from "../runtime/sdk-source.js";
|
|
18
19
|
import { sdkProblems } from "./sdk-check.js";
|
|
19
20
|
/**
|
|
20
21
|
* `dsm-package.yaml`: a Standalone Package, i.e. an unsigned native SPK that
|
|
@@ -23,13 +24,15 @@ import { sdkProblems } from "./sdk-check.js";
|
|
|
23
24
|
export const STANDALONE_MANIFEST = "dsm-package.yaml";
|
|
24
25
|
export const KNOWN_FIELDS = new Set([
|
|
25
26
|
"schema", "package", "version", "build", "displayname", "description",
|
|
26
|
-
"metadata", "maintainer", "os_min_ver", "arches", "backend", "ui", "capabilities", "i18n", "daemon", "install", "entry", "portal", "icon", "reproducible",
|
|
27
|
+
"metadata", "maintainer", "os_min_ver", "arches", "backend", "ui", "capabilities", "i18n", "daemon", "install", "uninstall", "entry", "portal", "icon", "reproducible",
|
|
27
28
|
]);
|
|
28
29
|
const I18N_FIELDS = new Set(["messages", "glossary", "info"]);
|
|
29
30
|
const PACKAGE_ID = /^[A-Za-z][A-Za-z0-9]{1,63}$/;
|
|
30
31
|
const VERSION = /^\d+(\.\d+){0,3}$/;
|
|
31
32
|
const OS_MIN_VER = /^\d+\.\d+(\.\d+)?-\d+$/;
|
|
32
33
|
const GO_MAIN = /^\.\/[A-Za-z0-9_./-]*$/;
|
|
34
|
+
/** A Cargo package or binary name. */
|
|
35
|
+
const CARGO_NAME = /^[A-Za-z][A-Za-z0-9_-]{0,63}$/;
|
|
33
36
|
function isRecord(value) {
|
|
34
37
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
35
38
|
}
|
|
@@ -132,17 +135,19 @@ function readManifest(raw, c) {
|
|
|
132
135
|
const arches = readArches(raw.arches, c);
|
|
133
136
|
const backend = arches && readBackend(raw.backend, arches, c);
|
|
134
137
|
let permissions;
|
|
135
|
-
if (backend
|
|
138
|
+
if (backend && backend.kind !== "binary") {
|
|
136
139
|
try {
|
|
137
|
-
permissions = readApiPermissions(backend
|
|
140
|
+
permissions = readApiPermissions(backend, capabilities);
|
|
138
141
|
}
|
|
139
142
|
catch (error) {
|
|
140
|
-
// The SDK's own routes come from its module; a failed download
|
|
141
|
-
|
|
143
|
+
// The SDK's own routes come from its module; a failed download or an
|
|
144
|
+
// untagged SDK (reported by sdkProblems) is not a policy problem.
|
|
145
|
+
const message = error.message;
|
|
146
|
+
const download = /^\[SDK_DOWNLOAD_FAILED\] ([\s\S]*)$/.exec(message);
|
|
142
147
|
if (download)
|
|
143
148
|
c.fail("SDK_DOWNLOAD_FAILED", download[1]);
|
|
144
|
-
else
|
|
145
|
-
c.fail("STANDALONE_API_PERMISSIONS_INVALID", `backend
|
|
149
|
+
else if (!message.startsWith("[SDK_TAG_REQUIRED]"))
|
|
150
|
+
c.fail("STANDALONE_API_PERMISSIONS_INVALID", `backend/${API_PERMISSIONS_FILE[backend.kind]} must define the centralized API policy: ${String(error)}`);
|
|
146
151
|
}
|
|
147
152
|
}
|
|
148
153
|
const daemon = arches && readDaemon(raw.daemon, c.root, arches, e => c.fail(e.code, e.message));
|
|
@@ -169,20 +174,25 @@ function readManifest(raw, c) {
|
|
|
169
174
|
c.fail("CAPABILITY_COMBINATION_INVALID", "capabilities.identity 需要獨立登入頁;plain UI 不支援,請改用 vue UI(init --ui vue)。");
|
|
170
175
|
if (capabilities.delegate?.scope === "install-selected" && ui && !ui.build)
|
|
171
176
|
c.fail("CAPABILITY_COMBINATION_INVALID", "delegate scope install-selected 只能在 Vue 面板確認;plain UI 不支援,請改用 vue UI(init --ui vue)。");
|
|
172
|
-
if (backend
|
|
173
|
-
for (const problem of sdkProblems(backend
|
|
177
|
+
if (backend && backend.kind !== "binary") {
|
|
178
|
+
for (const problem of sdkProblems(backend, ui?.build?.cwd, Boolean(capabilities.delegate || capabilities.rootOps)))
|
|
174
179
|
c.fail(problem.code, problem.message);
|
|
175
180
|
}
|
|
176
181
|
const i18n = raw.i18n === undefined ? undefined : readI18n(raw.i18n, c);
|
|
177
182
|
const icon = raw.icon === undefined ? undefined : readIcon(raw.icon, c);
|
|
183
|
+
const uninstall = readUninstall(raw.uninstall, { rootOps: capabilities.rootOps === true, ops: rootOps, en: i18n?.catalogs?.en }, e => c.fail(e.code, e.message));
|
|
178
184
|
const portal = raw.portal === undefined || !packageId ? undefined : readPortalDeclaration(raw.portal, packageId)
|
|
179
185
|
?? c.fail("STANDALONE_PORTAL_INVALID", "portal 必須是 {} 或 { alias: <小寫英數與 -,1–64 字,不可為 DSM 保留路徑> }。");
|
|
180
186
|
if (portal && !capabilities.rootOps && !capabilities.delegate) {
|
|
181
187
|
c.fail("STANDALONE_PORTAL_REQUIRES_EXECUTOR", PORTAL_REQUIRES_EXECUTOR);
|
|
182
188
|
}
|
|
183
|
-
if (portal && osMinVer && !isDsmAtLeast(osMinVer,
|
|
189
|
+
if (portal && osMinVer && !isDsmAtLeast(osMinVer, PORTAL_EXECUTOR_MIN_DSM)) {
|
|
184
190
|
c.fail("STANDALONE_PORTAL_MIN_DSM", portalMinDsmMessage(osMinVer, "portal"));
|
|
185
191
|
}
|
|
192
|
+
const entryProblem = portal && packageId && displayname && osMinVer && portalByExecutor(osMinVer)
|
|
193
|
+
? portalEntryProblem(packageId, { application: displayname, alias: portal.alias }) : undefined;
|
|
194
|
+
if (entryProblem)
|
|
195
|
+
c.fail("STANDALONE_PORTAL_ENTRY_INVALID", entryProblem);
|
|
186
196
|
if (!packageId || !version || !build || !osMinVer || !displayname || !description || !maintainer
|
|
187
197
|
|| !arches || !backend || !ui || (capabilities.rootOps === true && !rootOps)) {
|
|
188
198
|
return undefined;
|
|
@@ -206,12 +216,17 @@ function readManifest(raw, c) {
|
|
|
206
216
|
capabilities,
|
|
207
217
|
...(rootOps ? { rootOps } : {}),
|
|
208
218
|
...(daemon ? { daemon } : {}),
|
|
219
|
+
...(uninstall ? { uninstall } : {}),
|
|
209
220
|
...(sharedFolder ? { sharedFolder } : {}),
|
|
210
221
|
...(entry ? { entry } : {}),
|
|
211
222
|
...(portal ? { portal } : {}),
|
|
212
223
|
...(icon ? { icon } : {}),
|
|
213
224
|
};
|
|
214
225
|
}
|
|
226
|
+
/** Where an App's SDK version is declared: its Go or Rust backend; a prebuilt binary declares it in the project's go.mod. */
|
|
227
|
+
export function sdkBackend(m) {
|
|
228
|
+
return m.backend.kind === "binary" ? { kind: "go", dir: m.root } : m.backend;
|
|
229
|
+
}
|
|
215
230
|
function readIcon(value, c) {
|
|
216
231
|
const path = c.path(value, "icon");
|
|
217
232
|
if (!path)
|
|
@@ -224,6 +239,20 @@ function readIcon(value, c) {
|
|
|
224
239
|
return c.fail("STANDALONE_ICON_INVALID", `icon:${error.message}`);
|
|
225
240
|
}
|
|
226
241
|
}
|
|
242
|
+
/** Where each backend kind's CGI main wraps its authenticator in the SDK entry gate. */
|
|
243
|
+
const ENTRY_GATE = {
|
|
244
|
+
go: { main: b => b.kind === "go" ? join(b.dir, b.main, "main.go") : "", call: "dsm.NewEntryGate(", sdk: `backend/go.mod 要 require ${SDK_MODULE}` },
|
|
245
|
+
rust: { main: b => b.kind === "rust" ? join(b.dir, "src/bin", `${b.bin}.rs`) : "", call: "new_entry_gate(", sdk: `backend/Cargo.toml 要以 release tag 相依 ${SDK_CRATE}` },
|
|
246
|
+
};
|
|
247
|
+
/** The SDK version, or none when the backend has none or pins it wrongly (reported by sdkProblems). */
|
|
248
|
+
function sdkVersionOrUndefined(backend) {
|
|
249
|
+
try {
|
|
250
|
+
return appSdkVersion(backend);
|
|
251
|
+
}
|
|
252
|
+
catch {
|
|
253
|
+
return undefined;
|
|
254
|
+
}
|
|
255
|
+
}
|
|
227
256
|
/**
|
|
228
257
|
* The CGI enforces the entry mode only when the project's backend carries the
|
|
229
258
|
* SDK gate and main wires it; `-X main.entry` on an older main would be
|
|
@@ -235,11 +264,12 @@ function readEntry(value, backend, policy, c) {
|
|
|
235
264
|
}
|
|
236
265
|
if (!backend)
|
|
237
266
|
return undefined;
|
|
238
|
-
const
|
|
239
|
-
|
|
240
|
-
|
|
267
|
+
const gate = ENTRY_GATE[backend.kind];
|
|
268
|
+
const main = gate?.main(backend) ?? "";
|
|
269
|
+
if (!gate || !sdkVersionOrUndefined(backend)
|
|
270
|
+
|| !existsSync(main) || !readFileSync(main, "utf8").includes(gate.call)) {
|
|
241
271
|
const shown = main ? relative(c.root, main) : "backend.main 的 main.go";
|
|
242
|
-
return c.fail("STANDALONE_ENTRY_UNSUPPORTED", `entry 需要 SDK 的 entry gate
|
|
272
|
+
return c.fail("STANDALONE_ENTRY_UNSUPPORTED", `entry 需要 SDK 的 entry gate:${gate?.sdk ?? `backend/go.mod 要 require ${SDK_MODULE}`},並在 ${shown} 以 ${gate?.call.slice(0, -1) ?? "dsm.NewEntryGate"} 包住 authenticator。`);
|
|
243
273
|
}
|
|
244
274
|
if (value === "app-privilege" && !permitsOrdinaryUsers(policy)) {
|
|
245
275
|
return c.fail("STANDALONE_ENTRY_INVALID", "entry: app-privilege 需要至少一個 permissions.json 標為 authenticated 的 route,一般使用者才有功能可用。");
|
|
@@ -270,9 +300,11 @@ function readArches(value, c) {
|
|
|
270
300
|
: c.fail("STANDALONE_ARCHES_INVALID", "arches 必須是不重複的 x86_64/aarch64/armv7 清單,至少一個。");
|
|
271
301
|
}
|
|
272
302
|
function readBackend(value, arches, c) {
|
|
273
|
-
if (!isRecord(value) || !["go", "binary"].includes(String(value.kind))) {
|
|
274
|
-
return c.fail("STANDALONE_BACKEND_INVALID", "backend.kind 必須是 go 或 binary。");
|
|
303
|
+
if (!isRecord(value) || !["go", "rust", "binary"].includes(String(value.kind))) {
|
|
304
|
+
return c.fail("STANDALONE_BACKEND_INVALID", "backend.kind 必須是 go、rust 或 binary。");
|
|
275
305
|
}
|
|
306
|
+
if (value.kind === "rust")
|
|
307
|
+
return readRustBackend(value, c);
|
|
276
308
|
if (value.kind === "go") {
|
|
277
309
|
if (Object.keys(value).some(key => !["kind", "dir", "main"].includes(key)) || (value.main !== undefined && (typeof value.main !== "string" || !GO_MAIN.test(value.main) || value.main.includes("..")))) {
|
|
278
310
|
return c.fail("STANDALONE_BACKEND_INVALID", "backend.kind: go 需要 dir,可選 main(預設 ./cmd/app)。");
|
|
@@ -299,6 +331,16 @@ function readBackend(value, arches, c) {
|
|
|
299
331
|
}
|
|
300
332
|
return Object.values(files).every(Boolean) ? { kind: "binary", files } : undefined;
|
|
301
333
|
}
|
|
334
|
+
function readRustBackend(value, c) {
|
|
335
|
+
const { package: pkg, bin = "api", worker = "background-worker" } = value;
|
|
336
|
+
const names = [pkg, bin, worker];
|
|
337
|
+
if (Object.keys(value).some(key => !["kind", "dir", "package", "bin", "worker"].includes(key))
|
|
338
|
+
|| !names.every(name => typeof name === "string" && CARGO_NAME.test(name)) || bin === worker) {
|
|
339
|
+
return c.fail("STANDALONE_BACKEND_INVALID", "backend.kind: rust 需要 dir 與 package(Cargo package),可選 bin(CGI,預設 api)與 worker(背景程序,預設 background-worker),兩者不可相同。");
|
|
340
|
+
}
|
|
341
|
+
const dir = c.path(value.dir, "backend.dir");
|
|
342
|
+
return dir ? { kind: "rust", dir, package: pkg, bin: bin, worker: worker } : undefined;
|
|
343
|
+
}
|
|
302
344
|
function readUi(value, c) {
|
|
303
345
|
if (!isRecord(value)) {
|
|
304
346
|
return c.fail("STANDALONE_UI_INVALID", "ui 必須包含 dir。");
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { type PackageMetadata } from "../package-metadata.js";
|
|
2
2
|
import type { CommandRunner } from './build.js';
|
|
3
|
+
import { type Toolkit } from './toolkit.js';
|
|
4
|
+
import { type PortalDeclaration } from '../portal/alias.js';
|
|
3
5
|
export declare const LAYOUT_ARCHES: {
|
|
4
6
|
readonly x86_64: readonly [2, 62];
|
|
5
7
|
readonly aarch64: readonly [2, 183];
|
|
@@ -12,10 +14,6 @@ type Hook = {
|
|
|
12
14
|
cwd: string;
|
|
13
15
|
env: Record<string, string>;
|
|
14
16
|
};
|
|
15
|
-
type Toolkit = {
|
|
16
|
-
url: string;
|
|
17
|
-
sha256: string;
|
|
18
|
-
};
|
|
19
17
|
type Binary = {
|
|
20
18
|
path: string;
|
|
21
19
|
linkage: 'static' | 'dynamic';
|
|
@@ -50,6 +48,8 @@ export interface NativeLayout {
|
|
|
50
48
|
hook: Hook;
|
|
51
49
|
toolkits: Partial<Record<LayoutArch, Toolkit[]>>;
|
|
52
50
|
};
|
|
51
|
+
/** The product's own root helper registers this Application Portal entry; the CLI only names it in INFO. */
|
|
52
|
+
portal?: PortalDeclaration;
|
|
53
53
|
}
|
|
54
54
|
export declare function isNativeLayout(root: string): boolean;
|
|
55
55
|
export declare function loadNativeLayout(root: string): {
|
|
@@ -62,9 +62,6 @@ export declare function loadNativeLayout(root: string): {
|
|
|
62
62
|
message: string;
|
|
63
63
|
}[];
|
|
64
64
|
};
|
|
65
|
-
export declare function verifyToolkitArchive(path: string, expected: string): void;
|
|
66
|
-
/** One cache per user, shared by every project and worktree; archives are checked against sha256 on every use. */
|
|
67
|
-
export declare function toolkitCacheDir(env: Record<string, string | undefined>, platform: string, home: string): string;
|
|
68
65
|
export declare function layoutFiles(root: string): Array<{
|
|
69
66
|
path: string;
|
|
70
67
|
mode: number;
|
|
@@ -1,15 +1,17 @@
|
|
|
1
1
|
import { validateMetadata, metadataInfoFields } from "../package-metadata.js";
|
|
2
2
|
/** Existing native products: the CLI owns archives/INFO, the project owns its layout. */
|
|
3
3
|
import { createHash } from 'node:crypto';
|
|
4
|
-
import { chmodSync, copyFileSync, existsSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, realpathSync,
|
|
5
|
-
import {
|
|
4
|
+
import { chmodSync, copyFileSync, existsSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, realpathSync, rmSync, writeFileSync } from 'node:fs';
|
|
5
|
+
import { tmpdir } from 'node:os';
|
|
6
6
|
import { dirname, isAbsolute, join, relative, resolve } from 'node:path';
|
|
7
7
|
import { parse } from 'yaml';
|
|
8
8
|
import { createReproducibleTar } from '../build-spk/reproducible-archive.js';
|
|
9
9
|
import { runCommand, runnerFor } from './build.js';
|
|
10
10
|
import { checkJobs, commitOutputs, forEachArch } from './arch-jobs.js';
|
|
11
|
+
import { prepareToolkit } from './toolkit.js';
|
|
11
12
|
import { validateReproducible } from '../reproducible-config.js';
|
|
12
|
-
import { resolveMinDsm } from '../build-spk/dsm-version.js';
|
|
13
|
+
import { isDsmAtLeast, PORTAL_EXECUTOR_MIN_DSM, resolveMinDsm } from '../build-spk/dsm-version.js';
|
|
14
|
+
import { portalEntryProblem, readPortalDeclaration } from '../portal/alias.js';
|
|
13
15
|
import { infoArchValue } from '../build-spk/dsm-arch.js';
|
|
14
16
|
import { LIFECYCLE_SCRIPTS, REPLACE_SCRIPTS } from '../spk/dsm-spec.js';
|
|
15
17
|
import { renderPackageIcons, writePackageCenterIcons } from '../build-spk/dsm-icons.js';
|
|
@@ -58,6 +60,19 @@ function anchors(value) {
|
|
|
58
60
|
}
|
|
59
61
|
return a;
|
|
60
62
|
}
|
|
63
|
+
// Like the anchors, INFO is the root helper's trusted copy of the entry it
|
|
64
|
+
// registers in DSM's app.d: the alias, under the INFO displayname.
|
|
65
|
+
const PORTAL_INFO_KEY = 'synopkgland_portal_alias';
|
|
66
|
+
function portal(value, packageId) {
|
|
67
|
+
return readPortalDeclaration(value, packageId) ?? fail('portal must be {} or {alias: <lowercase letters, digits and -, 1-64, not a DSM path>}');
|
|
68
|
+
}
|
|
69
|
+
function checkPortal(p, packageId, displayname, osMinVer) {
|
|
70
|
+
if (!isDsmAtLeast(osMinVer, PORTAL_EXECUTOR_MIN_DSM))
|
|
71
|
+
fail(`portal needs DSM ${PORTAL_EXECUTOR_MIN_DSM} or later, where the app.d entry format is verified; os_min_ver is ${osMinVer}`);
|
|
72
|
+
const problem = portalEntryProblem(packageId, { application: displayname, alias: p.alias });
|
|
73
|
+
if (problem)
|
|
74
|
+
fail(problem);
|
|
75
|
+
}
|
|
61
76
|
function hook(value) {
|
|
62
77
|
const h = object(value, ['command', 'cwd', 'env']);
|
|
63
78
|
if (!Array.isArray(h.command) || !h.command.length || !h.command.every((x) => typeof x === 'string' && x.length > 0 && !x.includes('\0')))
|
|
@@ -90,7 +105,7 @@ export function isNativeLayout(root) {
|
|
|
90
105
|
}
|
|
91
106
|
export function loadNativeLayout(root) {
|
|
92
107
|
try {
|
|
93
|
-
const m = object(parse(readFileSync(join(root, 'dsm-package.yaml'), 'utf8')), ['schema', 'backend', 'package', 'version', 'build', 'displayname', 'description', 'maintainer', 'metadata', 'os_min_ver', 'privilege', 'arches', 'layout', 'native', 'icon', 'reproducible']);
|
|
108
|
+
const m = object(parse(readFileSync(join(root, 'dsm-package.yaml'), 'utf8')), ['schema', 'backend', 'package', 'version', 'build', 'displayname', 'description', 'maintainer', 'metadata', 'os_min_ver', 'privilege', 'arches', 'layout', 'native', 'icon', 'reproducible', 'portal']);
|
|
94
109
|
if (m.schema !== 2)
|
|
95
110
|
fail('schema 2 required; migrate schema: 1 to schema: 2 and add backend: {kind: native}');
|
|
96
111
|
const backend = object(m.backend, ['kind']);
|
|
@@ -113,6 +128,10 @@ export function loadNativeLayout(root) {
|
|
|
113
128
|
m.icon = icon(root, m.icon);
|
|
114
129
|
if (m.reproducible !== undefined)
|
|
115
130
|
validateReproducible(m.reproducible);
|
|
131
|
+
if (m.portal !== undefined) {
|
|
132
|
+
m.portal = portal(m.portal, m.package);
|
|
133
|
+
checkPortal(m.portal, m.package, m.displayname, m.os_min_ver);
|
|
134
|
+
}
|
|
116
135
|
const layout = object(m.layout, ['payload', 'metadata', 'binaries', 'anchors']);
|
|
117
136
|
layout.payload = pathText(layout.payload);
|
|
118
137
|
layout.metadata = pathText(layout.metadata);
|
|
@@ -146,100 +165,6 @@ export function loadNativeLayout(root) {
|
|
|
146
165
|
return { ok: false, errors: [{ code: 'NATIVE_LAYOUT_INVALID', message: String(error) }] };
|
|
147
166
|
}
|
|
148
167
|
}
|
|
149
|
-
export function verifyToolkitArchive(path, expected) {
|
|
150
|
-
if (!lstatSync(path).isFile() || digest(readFileSync(path)) !== expected)
|
|
151
|
-
fail('toolkit checksum mismatch');
|
|
152
|
-
}
|
|
153
|
-
/** One cache per user, shared by every project and worktree; archives are checked against sha256 on every use. */
|
|
154
|
-
export function toolkitCacheDir(env, platform, home) {
|
|
155
|
-
if (env.SYNOPKGLAND_CACHE_DIR)
|
|
156
|
-
return resolve(env.SYNOPKGLAND_CACHE_DIR);
|
|
157
|
-
if (platform === 'darwin')
|
|
158
|
-
return join(home, 'Library', 'Caches', 'synopkgland');
|
|
159
|
-
const xdg = env.XDG_CACHE_HOME;
|
|
160
|
-
return join(xdg && isAbsolute(xdg) ? xdg : join(home, '.cache'), 'synopkgland');
|
|
161
|
-
}
|
|
162
|
-
// Concurrent architectures that pin the same archive share one download.
|
|
163
|
-
const downloads = new Map();
|
|
164
|
-
async function download(m, cache, input, archive, run) {
|
|
165
|
-
const tmp = mkdtempSync(join(cache, '.download-'));
|
|
166
|
-
try {
|
|
167
|
-
const out = join(tmp, 'archive');
|
|
168
|
-
const r = await run('curl', ['--fail', '--location', '--proto', '=https', '--proto-redir', '=https', '--retry', '2', '--retry-all-errors', '--connect-timeout', '20', '--max-time', '600', '--output', out, input.url], { cwd: m.root, env: { ...process.env } });
|
|
169
|
-
if (r.status !== 0)
|
|
170
|
-
fail('toolkit download failed');
|
|
171
|
-
// Rename is atomic, so another build process sees either no archive or a verified one.
|
|
172
|
-
verifyToolkitArchive(out, input.sha256);
|
|
173
|
-
renameSync(out, archive);
|
|
174
|
-
}
|
|
175
|
-
finally {
|
|
176
|
-
rmSync(tmp, { recursive: true, force: true });
|
|
177
|
-
}
|
|
178
|
-
}
|
|
179
|
-
async function toolkitFor(m, arch, run) {
|
|
180
|
-
const inputs = m.native.toolkits[arch] ?? [];
|
|
181
|
-
if (!inputs.length)
|
|
182
|
-
return '';
|
|
183
|
-
const cache = toolkitCacheDir(process.env, process.platform, homedir());
|
|
184
|
-
mkdirSync(cache, { recursive: true, mode: 0o700 });
|
|
185
|
-
if (lstatSync(cache).isSymbolicLink() || (lstatSync(cache).mode & 0o022))
|
|
186
|
-
fail('toolkit cache must be owned and private');
|
|
187
|
-
const archives = [];
|
|
188
|
-
for (const input of inputs) {
|
|
189
|
-
const archive = join(cache, `${input.sha256}.txz`);
|
|
190
|
-
if (!existsSync(archive)) {
|
|
191
|
-
let pending = downloads.get(archive);
|
|
192
|
-
if (!pending) {
|
|
193
|
-
pending = download(m, cache, input, archive, run).finally(() => downloads.delete(archive));
|
|
194
|
-
downloads.set(archive, pending);
|
|
195
|
-
}
|
|
196
|
-
await pending;
|
|
197
|
-
}
|
|
198
|
-
verifyToolkitArchive(archive, input.sha256);
|
|
199
|
-
archives.push(archive);
|
|
200
|
-
}
|
|
201
|
-
// Re-extract verified bytes: a package-writable marker never authenticates an extracted tree.
|
|
202
|
-
const stage = mkdtempSync(join(cache, `toolkit-${arch}-`));
|
|
203
|
-
try {
|
|
204
|
-
const flavor = await tarFlavor(m, run);
|
|
205
|
-
for (const archive of archives) {
|
|
206
|
-
if (flavor === 'bsdtar')
|
|
207
|
-
await refuseEscapingMembers(m, archive, run);
|
|
208
|
-
const r = await run('tar', flavor === 'gnu' ? ['-xJf', archive, '-C', stage, '--keep-directory-symlink'] : ['-xPJf', archive, '-C', stage], { cwd: m.root, env: { ...process.env } });
|
|
209
|
-
if (r.status !== 0)
|
|
210
|
-
fail('toolkit extraction failed');
|
|
211
|
-
}
|
|
212
|
-
return stage;
|
|
213
|
-
}
|
|
214
|
-
catch (e) {
|
|
215
|
-
rmSync(stage, { recursive: true, force: true });
|
|
216
|
-
throw e;
|
|
217
|
-
}
|
|
218
|
-
}
|
|
219
|
-
/**
|
|
220
|
-
* A later toolkit archive may write through a directory symlink an earlier one
|
|
221
|
-
* created. GNU tar keeps such a symlink only with --keep-directory-symlink;
|
|
222
|
-
* bsdtar only with -P, which also allows absolute and .. members, so those are
|
|
223
|
-
* refused from the listing first.
|
|
224
|
-
*/
|
|
225
|
-
async function tarFlavor(m, run) {
|
|
226
|
-
const r = await run('tar', ['--version'], { cwd: m.root, env: { ...process.env }, capture: true });
|
|
227
|
-
const version = r.status === 0 ? r.stdout ?? '' : '';
|
|
228
|
-
if (/\bGNU tar\b/.test(version))
|
|
229
|
-
return 'gnu';
|
|
230
|
-
if (/\bbsdtar\b/.test(version))
|
|
231
|
-
return 'bsdtar';
|
|
232
|
-
throw Object.assign(new Error(`NATIVE_TOOLKIT_TAR_UNSUPPORTED: extracting native.toolkits needs GNU tar or bsdtar as \`tar\`; found ${r.missing ? 'no tar' : JSON.stringify(version.split('\n')[0] ?? '')}`), { code: 'NATIVE_TOOLKIT_TAR_UNSUPPORTED' });
|
|
233
|
-
}
|
|
234
|
-
async function refuseEscapingMembers(m, archive, run) {
|
|
235
|
-
const r = await run('tar', ['-tJf', archive], { cwd: m.root, env: { ...process.env }, capture: true });
|
|
236
|
-
if (r.status !== 0)
|
|
237
|
-
fail('toolkit extraction failed');
|
|
238
|
-
for (const member of (r.stdout ?? '').split('\n').filter(Boolean)) {
|
|
239
|
-
if (member.startsWith('/') || member.split('/').includes('..'))
|
|
240
|
-
fail(`toolkit member escapes the toolkit folder: ${member}`);
|
|
241
|
-
}
|
|
242
|
-
}
|
|
243
168
|
async function runHook(m, h, env, run) {
|
|
244
169
|
const [cmd, ...args] = h.command;
|
|
245
170
|
const r = await run(cmd, args, { cwd: inside(m.root, h.cwd), env: { ...process.env, ...h.env, ...env } });
|
|
@@ -363,6 +288,8 @@ export async function buildNativeLayout(options) {
|
|
|
363
288
|
fail('invalid build override');
|
|
364
289
|
m.build = options.build;
|
|
365
290
|
}
|
|
291
|
+
if (m.portal)
|
|
292
|
+
checkPortal(m.portal, m.package, m.displayname, m.os_min_ver);
|
|
366
293
|
const arches = options.arches ?? m.arches;
|
|
367
294
|
if (!arches.length || new Set(arches).size !== arches.length || arches.some(a => !m.arches.includes(a)))
|
|
368
295
|
fail('architecture not declared');
|
|
@@ -376,7 +303,7 @@ export async function buildNativeLayout(options) {
|
|
|
376
303
|
await runHook(m, m.native.prepare, env, options.run ?? runCommand);
|
|
377
304
|
const outputs = await commitOutputs(options.outDir ?? join(m.root, 'dist'), (outDir) => forEachArch(arches, jobs, async (arch) => {
|
|
378
305
|
const run = runnerFor(options.run, jobs, arch);
|
|
379
|
-
const toolkit = await
|
|
306
|
+
const toolkit = await prepareToolkit(m.root, arch, m.native.toolkits[arch] ?? [], run);
|
|
380
307
|
const stage = mkdtempSync(join(tmpdir(), 'synopkgland-layout-'));
|
|
381
308
|
const work = mkdtempSync(join(tmpdir(), 'synopkgland-work-'));
|
|
382
309
|
try {
|
|
@@ -402,11 +329,11 @@ export async function buildNativeLayout(options) {
|
|
|
402
329
|
await createReproducibleTar(target, join(stage, 'package.tgz'), { gzip: true });
|
|
403
330
|
rmSync(target, { recursive: true });
|
|
404
331
|
const fields = { package: m.package, version, arch: layoutInfoArch(arch), os_min_ver: m.os_min_ver, displayname: m.displayname, description: m.description, maintainer: m.maintainer, dsmuidir: 'ui', dsmappname: `SYNO.SDS.${m.package}.Instance`, startable: 'yes', thirdparty: 'yes', checksum: createHash('md5').update(readFileSync(join(stage, 'package.tgz'))).digest('hex') };
|
|
405
|
-
const info = [...Object.entries(fields), ...metadataInfoFields(m.metadata), ...anchorFields(m.layout.anchors ?? {}, files)];
|
|
332
|
+
const info = [...Object.entries(fields), ...metadataInfoFields(m.metadata), ...anchorFields(m.layout.anchors ?? {}, files), ...portalFields(m.portal)];
|
|
406
333
|
if (new Set(info.map(([k]) => k)).size !== info.length)
|
|
407
334
|
fail('INFO anchor collides with an INFO field');
|
|
408
335
|
writeFileSync(join(stage, 'INFO'), info.map(([k, v]) => `${k}="${v.replace(/([\\"$`])/g, '\\$1')}"\n`).join(''));
|
|
409
|
-
writeFileSync(join(stage, 'native-layout.json'), JSON.stringify({ format: 1, arch, binaries: binaries.map(({ path, linkage }) => ({ path, linkage })), files, ...(m.layout.anchors ? { anchors: m.layout.anchors } : {}) }));
|
|
336
|
+
writeFileSync(join(stage, 'native-layout.json'), JSON.stringify({ format: 1, arch, binaries: binaries.map(({ path, linkage }) => ({ path, linkage })), files, ...(m.layout.anchors ? { anchors: m.layout.anchors } : {}), ...(m.portal ? { portal: m.portal } : {}) }));
|
|
410
337
|
const output = join(outDir, `${m.package}-${version}-${arch}.spk`);
|
|
411
338
|
await createReproducibleTar(stage, output);
|
|
412
339
|
return output;
|
|
@@ -423,7 +350,7 @@ export function inspectNativeLayout(dir, payload, info) {
|
|
|
423
350
|
try {
|
|
424
351
|
if (!/^[A-Za-z][A-Za-z0-9]{1,63}$/.test(info.package ?? '') || !/^\d+(\.\d+){0,3}-\d+$/.test(info.version ?? '') || !/^7\.\d+(\.\d+)?-\d+$/.test(info.os_min_ver ?? ''))
|
|
425
352
|
fail('invalid native INFO identity/version/DSM');
|
|
426
|
-
const inv = object(JSON.parse(readFileSync(join(dir, 'native-layout.json'), 'utf8')), ['format', 'arch', 'binaries', 'files', 'anchors']);
|
|
353
|
+
const inv = object(JSON.parse(readFileSync(join(dir, 'native-layout.json'), 'utf8')), ['format', 'arch', 'binaries', 'files', 'anchors', 'portal']);
|
|
427
354
|
if (inv.format !== 1 || !Object.hasOwn(LAYOUT_ARCHES, inv.arch) || layoutInfoArch(inv.arch) !== info.arch)
|
|
428
355
|
fail('invalid native layout inventory');
|
|
429
356
|
checkLayoutMetadata(dir, true, info.package);
|
|
@@ -442,6 +369,10 @@ export function inspectNativeLayout(dir, payload, info) {
|
|
|
442
369
|
if (createHash('md5').update(readFileSync(join(dir, 'package.tgz'))).digest('hex') !== info.checksum)
|
|
443
370
|
fail('package checksum mismatch');
|
|
444
371
|
checkInfoAnchors(readFileSync(join(dir, 'INFO'), 'utf8'), inv.anchors === undefined ? {} : anchors(inv.anchors), actual);
|
|
372
|
+
const declared = inv.portal === undefined ? undefined : portal(inv.portal, info.package);
|
|
373
|
+
if (declared)
|
|
374
|
+
checkPortal(declared, info.package, info.displayname ?? '', info.os_min_ver);
|
|
375
|
+
checkInfoPortal(readFileSync(join(dir, 'INFO'), 'utf8'), declared);
|
|
445
376
|
return [];
|
|
446
377
|
}
|
|
447
378
|
catch (e) {
|
|
@@ -457,3 +388,11 @@ function checkInfoAnchors(info, declared, files) {
|
|
|
457
388
|
if (JSON.stringify(lines.sort()) !== JSON.stringify(expected.sort()))
|
|
458
389
|
fail('INFO anchors do not match the payload');
|
|
459
390
|
}
|
|
391
|
+
function portalFields(declared) {
|
|
392
|
+
return declared ? [[PORTAL_INFO_KEY, declared.alias]] : [];
|
|
393
|
+
}
|
|
394
|
+
function checkInfoPortal(info, declared) {
|
|
395
|
+
const lines = info.split('\n').filter(line => line.startsWith('synopkgland_portal_'));
|
|
396
|
+
if (JSON.stringify(lines) !== JSON.stringify(portalFields(declared).map(([key, value]) => `${key}="${value}"`)))
|
|
397
|
+
fail('INFO portal alias does not match the declared portal');
|
|
398
|
+
}
|
|
@@ -16,7 +16,8 @@ export interface RootTask {
|
|
|
16
16
|
* - authorize: DSM's own password confirmation, then one disabled root task
|
|
17
17
|
* with exactly the embedded script. The one-use token goes into that single
|
|
18
18
|
* create call and is never kept.
|
|
19
|
-
* - run: re-reads the task and runs it only if it is still exactly ours
|
|
19
|
+
* - run: re-reads the task and runs it only if it is still exactly ours (the
|
|
20
|
+
* first run after an authorize uses the task that authorize just read back), then
|
|
20
21
|
* waits for its history entry and asks the backend for the output. A queued
|
|
21
22
|
* or running task keeps the wait going, up to RUN_LIMIT_MS (loaded NAS took
|
|
22
23
|
* 4.5 minutes), and `options.progress({op, state, elapsedMs})` hears each poll. Repair
|
|
@@ -7,7 +7,8 @@ import { versionUnsupported } from "../runtime/dsm-version.js";
|
|
|
7
7
|
* - authorize: DSM's own password confirmation, then one disabled root task
|
|
8
8
|
* with exactly the embedded script. The one-use token goes into that single
|
|
9
9
|
* create call and is never kept.
|
|
10
|
-
* - run: re-reads the task and runs it only if it is still exactly ours
|
|
10
|
+
* - run: re-reads the task and runs it only if it is still exactly ours (the
|
|
11
|
+
* first run after an authorize uses the task that authorize just read back), then
|
|
11
12
|
* waits for its history entry and asks the backend for the output. A queued
|
|
12
13
|
* or running task keeps the wait going, up to RUN_LIMIT_MS (loaded NAS took
|
|
13
14
|
* 4.5 minutes), and `options.progress({op, state, elapsedMs})` hears each poll. Repair
|
|
@@ -18,8 +19,9 @@ import { versionUnsupported } from "../runtime/dsm-version.js";
|
|
|
18
19
|
*/
|
|
19
20
|
export const ROOT_RUNTIME = `function createRootAccess(options) {
|
|
20
21
|
var API = "SYNO.Core.TaskScheduler";
|
|
21
|
-
var POLL_MS = 500, MAX_POLL_MS =
|
|
22
|
-
|
|
22
|
+
var POLL_MS = 500, MAX_POLL_MS = 1000, RUN_LIMIT_MS = 10 * 60 * 1000;
|
|
23
|
+
// The task an authorize just read back as exactly ours, for the run that follows.
|
|
24
|
+
var byOp = {}, running = {}, verified = {};
|
|
23
25
|
var legacy = null, probe = null;
|
|
24
26
|
var versionUnsupported = ${versionUnsupported.toString()};
|
|
25
27
|
options.tasks.forEach(function (task) { byOp[task.op] = task; });
|
|
@@ -218,9 +220,14 @@ export const ROOT_RUNTIME = `function createRootAccess(options) {
|
|
|
218
220
|
});
|
|
219
221
|
});
|
|
220
222
|
}
|
|
223
|
+
function verify(op) {
|
|
224
|
+
var ref = verified[op];
|
|
225
|
+
delete verified[op];
|
|
226
|
+
return ref && op !== "helper_repair" ? Promise.resolve({ state: "ready", ref: ref }) : find(byOp[op]);
|
|
227
|
+
}
|
|
221
228
|
function performRun(op) {
|
|
222
229
|
return begin(op).then(function (task) {
|
|
223
|
-
return
|
|
230
|
+
return verify(op).then(function (found) {
|
|
224
231
|
if (found.state !== "ready") throw fail("root_not_ready");
|
|
225
232
|
var consent = op === "helper_repair" ? options.confirmPassword() : Promise.resolve({ confirmed: true });
|
|
226
233
|
return consent.then(function (confirmation) {
|
|
@@ -239,9 +246,10 @@ export const ROOT_RUNTIME = `function createRootAccess(options) {
|
|
|
239
246
|
return begin(op).then(find).then(function (found) { return found.state; });
|
|
240
247
|
},
|
|
241
248
|
authorize: function (op) {
|
|
249
|
+
delete verified[op];
|
|
242
250
|
return begin(op).then(function (task) {
|
|
243
251
|
return find(task).then(function (found) {
|
|
244
|
-
if (found.state === "ready") return "ready";
|
|
252
|
+
if (found.state === "ready") { verified[op] = found.ref; return "ready"; }
|
|
245
253
|
if (found.state === "conflict") throw fail("root_conflict");
|
|
246
254
|
return options.confirmPassword().then(function (confirmation) {
|
|
247
255
|
if (!confirmation || confirmation.confirmed !== true) return "cancelled";
|
|
@@ -259,6 +267,7 @@ export const ROOT_RUNTIME = `function createRootAccess(options) {
|
|
|
259
267
|
return find(task);
|
|
260
268
|
}).then(function (after) {
|
|
261
269
|
if (after.state !== "ready") throw fail("root_verify_failed");
|
|
270
|
+
verified[op] = after.ref;
|
|
262
271
|
return "ready";
|
|
263
272
|
});
|
|
264
273
|
});
|
|
@@ -277,6 +286,7 @@ export const ROOT_RUNTIME = `function createRootAccess(options) {
|
|
|
277
286
|
return pending;
|
|
278
287
|
},
|
|
279
288
|
remove: function (op) {
|
|
289
|
+
delete verified[op];
|
|
280
290
|
return begin(op).then(function (task) {
|
|
281
291
|
return find(task).then(function (found) {
|
|
282
292
|
if (found.state === "missing") return "missing";
|
|
@@ -12,8 +12,10 @@ export declare const PORTAL_MESSAGES: Record<string, Record<PortalLocale, string
|
|
|
12
12
|
* Control Panel shortcut.
|
|
13
13
|
*
|
|
14
14
|
* `status()` resolves `{state, alias?, url?, canManage, controlPanel,
|
|
15
|
-
* defaultAlias}`; `apply(alias)` and `disable()` resolve the new
|
|
16
|
-
* reject `{code}
|
|
15
|
+
* defaultAlias, entry?}`; `apply(alias)` and `disable()` resolve the new
|
|
16
|
+
* status or reject `{code}`. With `options.entry` (below DSM 7.2) the
|
|
17
|
+
* backend's `portal.status` also reports the executor's entry, and
|
|
18
|
+
* administrators get `register()` and `unregister()`; `openControlPanel()` opens Login Portal > Applications.
|
|
17
19
|
* `messages` is the panel text in the window's locale; `mountPanel(element)`
|
|
18
20
|
* renders a plain panel for UIs without their own.
|
|
19
21
|
*/
|