@synopackageland/cli 0.9.5 → 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 +36 -0
- package/assets/licenses/MUSL-COPYRIGHT +193 -0
- package/assets/licenses/RUST-COPYRIGHT-library.html +5556 -0
- package/dist/add.js +27 -27
- 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 +9 -0
- package/dist/build-spk/uninstall-wizard.js +10 -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 +33 -0
- package/dist/build-spk/wizard-messages.js +33 -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 +44 -3
- package/dist/info.js +2 -2
- package/dist/init.js +53 -28
- package/dist/runtime/executor-build.d.ts +1 -1
- package/dist/runtime/executor-build.js +2 -1
- package/dist/runtime/executor-digests.json +11 -0
- package/dist/runtime/executor-policy.d.ts +7 -0
- package/dist/runtime/executor-policy.js +5 -3
- package/dist/runtime/sdk-source.d.ts +14 -2
- package/dist/runtime/sdk-source.js +39 -4
- 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 +13 -9
- package/dist/standalone/daemon.d.ts +6 -0
- package/dist/standalone/daemon.js +8 -4
- package/dist/standalone/inspect-spk.d.ts +1 -1
- package/dist/standalone/inspect-spk.js +39 -10
- package/dist/standalone/lifecycle.js +38 -4
- package/dist/standalone/manifest.d.ts +12 -0
- package/dist/standalone/manifest.js +55 -17
- package/dist/standalone/native-layout.d.ts +1 -7
- package/dist/standalone/native-layout.js +4 -97
- 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 +14 -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
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import { copyFileSync, rmSync } from "node:fs";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { portalByExecutor } from "../build-spk/dsm-version.js";
|
|
5
|
+
import { SDK_GIT_URL } from "../runtime/sdk-source.js";
|
|
6
|
+
import { checkCgiBinary } from "./binary.js";
|
|
7
|
+
import { StandaloneBuildError } from "./spk.js";
|
|
8
|
+
import { prepareToolkit } from "./toolkit.js";
|
|
9
|
+
/** The glibc loader of DSM's ARMv7 models, the one interpreter an armv7 Rust CGI may name. */
|
|
10
|
+
export const ARMV7_INTERPRETER = "/lib/ld-linux-armhf.so.3";
|
|
11
|
+
const ARMV7_GCC = "usr/local/arm-unknown-linux-gnueabi/bin/arm-unknown-linux-gnueabi-gcc";
|
|
12
|
+
/**
|
|
13
|
+
* x86_64 and aarch64 link static musl binaries with rust-lld. armv7 links
|
|
14
|
+
* against the glibc of the DSM 7.0 toolkit, because musl's 64-bit time calls
|
|
15
|
+
* fail on DSM's ARMv7 kernels; that gcc runs on Linux x86_64 only.
|
|
16
|
+
*/
|
|
17
|
+
export const RUST_TARGETS = {
|
|
18
|
+
x86_64: { triple: "x86_64-unknown-linux-musl", linker: () => "rust-lld", linkArgs: ["--build-id=none"] },
|
|
19
|
+
aarch64: { triple: "aarch64-unknown-linux-musl", linker: () => "rust-lld", linkArgs: ["--build-id=none"] },
|
|
20
|
+
armv7: {
|
|
21
|
+
triple: "armv7-unknown-linux-gnueabihf",
|
|
22
|
+
linker: (toolkit) => join(toolkit, ARMV7_GCC),
|
|
23
|
+
linkArgs: ["-Wl,--build-id=none", "-Wl,--discard-all"],
|
|
24
|
+
toolkit: {
|
|
25
|
+
url: "https://global.synologydownload.com/download/ToolChain/toolkit/7.0/alpine4k/ds.alpine4k-7.0.env.txz",
|
|
26
|
+
sha256: "767cb27541e3c2aae5a07bc1078809956992c75a54e6bfca2275fa633e983407",
|
|
27
|
+
},
|
|
28
|
+
interpreter: ARMV7_INTERPRETER,
|
|
29
|
+
},
|
|
30
|
+
};
|
|
31
|
+
/** Go's `-X` values for a Rust App, read with `option_env!`; every one is set, empty when unused. */
|
|
32
|
+
export function rustBuildEnv(manifest) {
|
|
33
|
+
const { capabilities, executor } = manifest;
|
|
34
|
+
const delegate = capabilities.delegate;
|
|
35
|
+
return {
|
|
36
|
+
SYNOPKGLAND_PACKAGE: manifest.package,
|
|
37
|
+
SYNOPKGLAND_VERSION: manifest.spkVersion,
|
|
38
|
+
SYNOPKGLAND_PERMISSIONS_DIGEST: manifest.apiPermissionsDigest ?? "",
|
|
39
|
+
SYNOPKGLAND_SDK_PERMISSIONS_DIGEST: manifest.apiSDKPermissionsDigest ?? "",
|
|
40
|
+
SYNOPKGLAND_EXECUTOR_DIGEST: executor?.executorDigest ?? "",
|
|
41
|
+
SYNOPKGLAND_POLICY_DIGEST: executor?.policyDigest ?? "",
|
|
42
|
+
SYNOPKGLAND_DELEGATE_SCOPE: delegate?.scope ?? "",
|
|
43
|
+
SYNOPKGLAND_DELEGATE_OPERATIONS: delegate ? JSON.stringify([...delegate.operations].sort()) : "",
|
|
44
|
+
SYNOPKGLAND_DELEGATE_DESTINATIONS: delegate ? JSON.stringify(delegate.destinations ? [...delegate.destinations].sort() : []) : "",
|
|
45
|
+
SYNOPKGLAND_DELEGATE_LIFETIME: delegate?.lifetime ?? "",
|
|
46
|
+
SYNOPKGLAND_IDENTITY: capabilities.identity ?? "",
|
|
47
|
+
SYNOPKGLAND_FILE_STATION: capabilities.files || delegate ? "session" : "",
|
|
48
|
+
SYNOPKGLAND_ENTRY: manifest.entry ?? "",
|
|
49
|
+
SYNOPKGLAND_PORTAL: manifest.portal ? (portalByExecutor(manifest.os_min_ver) ? "executor" : "on") : "",
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
/** Builds the CGI bin, and the worker bin with capabilities.jobs, in one `cargo build` into `work`. */
|
|
53
|
+
export async function buildRustBackend(manifest, arch, work, run, { host = { platform: process.platform, arch: process.arch }, prepareToolkit: prepare = prepareToolkit } = {}) {
|
|
54
|
+
const backend = manifest.backend;
|
|
55
|
+
if (backend.kind !== "rust")
|
|
56
|
+
throw new Error("buildRustBackend needs backend.kind: rust");
|
|
57
|
+
const target = RUST_TARGETS[arch];
|
|
58
|
+
if (target.toolkit && (host.platform !== "linux" || host.arch !== "x64")) {
|
|
59
|
+
fail(`${arch} links with the gcc of the DSM toolkit, which runs on Linux x86_64 only; build ${arch} on Linux x86_64 (for example in a container).`);
|
|
60
|
+
}
|
|
61
|
+
const bins = [backend.bin, ...(manifest.capabilities.jobs ? [backend.worker] : [])];
|
|
62
|
+
const toolkit = target.toolkit ? await prepare(backend.dir, arch, [target.toolkit], run).catch((error) => fail(`DSM toolkit for ${arch}: ${error.message}`)) : "";
|
|
63
|
+
try {
|
|
64
|
+
const result = await run("cargo", [
|
|
65
|
+
"build", "--release", "--locked", "--target", target.triple, "-p", backend.package,
|
|
66
|
+
...bins.flatMap(bin => ["--bin", bin]), "--message-format=json-render-diagnostics",
|
|
67
|
+
], { cwd: backend.dir, env: cargoEnv(manifest, backend.dir, target, toolkit), capture: true });
|
|
68
|
+
if (result.missing)
|
|
69
|
+
fail("找不到 cargo。請以 rustup 安裝 Rust(https://rustup.rs);backend/rust-toolchain.toml 會選定版本與 target。");
|
|
70
|
+
if (result.status !== 0)
|
|
71
|
+
fail(`cargo build(${arch})失敗。\n${result.stderr}`);
|
|
72
|
+
const [cgi, worker] = bins.map((bin, i) => {
|
|
73
|
+
const out = join(work, i === 0 ? `api-${arch}.cgi` : `background-worker-${arch}`);
|
|
74
|
+
copyFileSync(artifact(result.stdout ?? "", bin), out);
|
|
75
|
+
const problem = checkCgiBinary(work, out, arch, target.interpreter);
|
|
76
|
+
if (problem)
|
|
77
|
+
throw new StandaloneBuildError(problem.code, problem.message);
|
|
78
|
+
return out;
|
|
79
|
+
});
|
|
80
|
+
return { cgi: cgi, ...(worker ? { worker } : {}) };
|
|
81
|
+
}
|
|
82
|
+
finally {
|
|
83
|
+
if (toolkit)
|
|
84
|
+
rmSync(toolkit, { recursive: true, force: true });
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* The environment of one reproducible cargo build: the CLI's flags replace any
|
|
89
|
+
* inherited RUSTFLAGS, paths are remapped, symbols and build IDs dropped. Cargo
|
|
90
|
+
* fetches the private SDK repository through git, with the user's credentials.
|
|
91
|
+
*/
|
|
92
|
+
function cargoEnv(manifest, dir, target, toolkit) {
|
|
93
|
+
const env = { ...process.env, ...rustBuildEnv(manifest) };
|
|
94
|
+
delete env.RUSTFLAGS;
|
|
95
|
+
const cargoHome = env.CARGO_HOME || join(homedir(), ".cargo");
|
|
96
|
+
env.CARGO_ENCODED_RUSTFLAGS = [
|
|
97
|
+
`--remap-path-prefix=${dir}=/build`,
|
|
98
|
+
`--remap-path-prefix=${cargoHome}=/cargo`,
|
|
99
|
+
"-Cstrip=symbols",
|
|
100
|
+
...target.linkArgs.map(arg => `-Clink-arg=${arg}`),
|
|
101
|
+
].join("\x1f");
|
|
102
|
+
env[`CARGO_TARGET_${target.triple.toUpperCase().replaceAll("-", "_")}_LINKER`] = target.linker(toolkit);
|
|
103
|
+
env.SOURCE_DATE_EPOCH = "0";
|
|
104
|
+
env.CARGO_NET_GIT_FETCH_WITH_CLI = "true";
|
|
105
|
+
if (env.SYNOPKGLAND_SDK_DIR) {
|
|
106
|
+
// Development only: git reads the SDK repository from this checkout, which must hold the locked tag.
|
|
107
|
+
const n = Number(env.GIT_CONFIG_COUNT ?? 0);
|
|
108
|
+
env.GIT_CONFIG_COUNT = String(n + 1);
|
|
109
|
+
env[`GIT_CONFIG_KEY_${n}`] = `url.file://${env.SYNOPKGLAND_SDK_DIR}.insteadOf`;
|
|
110
|
+
env[`GIT_CONFIG_VALUE_${n}`] = SDK_GIT_URL;
|
|
111
|
+
}
|
|
112
|
+
return env;
|
|
113
|
+
}
|
|
114
|
+
/** The executable Cargo reported for `bin`. */
|
|
115
|
+
function artifact(messages, bin) {
|
|
116
|
+
for (const line of messages.split("\n")) {
|
|
117
|
+
if (!line.startsWith("{"))
|
|
118
|
+
continue;
|
|
119
|
+
const message = JSON.parse(line);
|
|
120
|
+
if (message.reason === "compiler-artifact" && message.target?.kind?.includes("bin") && message.target.name === bin && message.executable)
|
|
121
|
+
return message.executable;
|
|
122
|
+
}
|
|
123
|
+
return fail(`cargo build 沒有產出 bin ${bin};確認 backend/src/bin/${bin}.rs 存在。`);
|
|
124
|
+
}
|
|
125
|
+
function fail(message) {
|
|
126
|
+
throw new StandaloneBuildError("STANDALONE_RUST_BUILD_FAILED", message);
|
|
127
|
+
}
|
|
@@ -1,9 +1,11 @@
|
|
|
1
|
+
import { type SdkBackend } from "../runtime/sdk-source.js";
|
|
1
2
|
export interface SdkProblem {
|
|
2
3
|
code: string;
|
|
3
4
|
message: string;
|
|
4
5
|
}
|
|
5
6
|
/**
|
|
6
|
-
* backend
|
|
7
|
-
* CLI must agree with it. `needsSdk`: the App declares
|
|
7
|
+
* The backend's dependency files name the App's SDK version; the frontend
|
|
8
|
+
* package and this CLI must agree with it. `needsSdk`: the App declares
|
|
9
|
+
* delegate or rootOps.
|
|
8
10
|
*/
|
|
9
|
-
export declare function sdkProblems(
|
|
11
|
+
export declare function sdkProblems(backend: SdkBackend, frontendDir: string | undefined, needsSdk: boolean, cliVersion?: string): SdkProblem[];
|
|
@@ -1,24 +1,37 @@
|
|
|
1
1
|
import { readFileSync } from "node:fs";
|
|
2
2
|
import { join, resolve } from "node:path";
|
|
3
3
|
import { CLI_VERSION } from "../cli-version.js";
|
|
4
|
-
import {
|
|
4
|
+
import { appSdkVersion, SDK_DEPENDENCY, SDK_PACKAGE } from "../runtime/sdk-source.js";
|
|
5
5
|
const minorLine = (version) => version.split(".").slice(0, 2).join(".");
|
|
6
|
+
/** The file that names each backend kind's SDK version. */
|
|
7
|
+
const SDK_VERSION_FILE = { go: "backend/go.mod", rust: "backend/Cargo.lock" };
|
|
6
8
|
/**
|
|
7
|
-
* backend
|
|
8
|
-
* CLI must agree with it. `needsSdk`: the App declares
|
|
9
|
+
* The backend's dependency files name the App's SDK version; the frontend
|
|
10
|
+
* package and this CLI must agree with it. `needsSdk`: the App declares
|
|
11
|
+
* delegate or rootOps.
|
|
9
12
|
*/
|
|
10
|
-
export function sdkProblems(
|
|
11
|
-
|
|
13
|
+
export function sdkProblems(backend, frontendDir, needsSdk, cliVersion = CLI_VERSION) {
|
|
14
|
+
let version;
|
|
15
|
+
try {
|
|
16
|
+
version = appSdkVersion(backend);
|
|
17
|
+
}
|
|
18
|
+
catch (error) {
|
|
19
|
+
const tag = /^\[SDK_TAG_REQUIRED\] ([\s\S]*)$/.exec(error.message);
|
|
20
|
+
if (tag)
|
|
21
|
+
return [{ code: "SDK_TAG_REQUIRED", message: tag[1] }];
|
|
22
|
+
throw error;
|
|
23
|
+
}
|
|
12
24
|
if (!version) {
|
|
13
|
-
return needsSdk ? [{ code: "SDK_REQUIRED", message: `capabilities.delegate 與 rootOps 需要 SDK
|
|
25
|
+
return needsSdk ? [{ code: "SDK_REQUIRED", message: `capabilities.delegate 與 rootOps 需要 SDK(${minorLine(cliVersion)}.x):${SDK_DEPENDENCY[backend.kind]}` }] : [];
|
|
14
26
|
}
|
|
27
|
+
const file = SDK_VERSION_FILE[backend.kind];
|
|
15
28
|
const problems = [];
|
|
16
29
|
if (minorLine(version) !== minorLine(cliVersion)) {
|
|
17
|
-
problems.push({ code: "SDK_UNSUPPORTED", message:
|
|
30
|
+
problems.push({ code: "SDK_UNSUPPORTED", message: `${file} 要求 SDK ${version};CLI ${cliVersion} 只支援 SDK ${minorLine(cliVersion)}.x。請改用對應的 CLI,或把 SDK 升到 ${minorLine(cliVersion)}.x。` });
|
|
18
31
|
}
|
|
19
32
|
const frontend = frontendDir === undefined ? undefined : frontendSdkVersion(frontendDir);
|
|
20
33
|
if (frontend && frontend.version !== version) {
|
|
21
|
-
problems.push({ code: "SDK_VERSION_MISMATCH", message: `frontend/package.json 的 ${SDK_PACKAGE} 是 ${frontend.spec}(${frontend.version ?? "讀不到版本"}
|
|
34
|
+
problems.push({ code: "SDK_VERSION_MISMATCH", message: `frontend/package.json 的 ${SDK_PACKAGE} 是 ${frontend.spec}(${frontend.version ?? "讀不到版本"}),${file} 要求 ${version};兩者必須相同。` });
|
|
22
35
|
}
|
|
23
36
|
return problems;
|
|
24
37
|
}
|
package/dist/standalone/spk.js
CHANGED
|
@@ -27,6 +27,7 @@ import { PORTAL_JSON_RELPATH, PORTAL_MUSTACHE_RELPATH, renderPortalJson, renderP
|
|
|
27
27
|
import { executorPlan } from "../runtime/executor-build.js";
|
|
28
28
|
import { EXECUTOR_ANCHOR } from "../runtime/executor-inspect.js";
|
|
29
29
|
import { renderSharedFolderUpgradeWizardFiles, sharedFolderInstallSteps } from "./shared-folder.js";
|
|
30
|
+
import { stateItemTexts, uninstallExecProblem, uninstallRecord } from "./uninstall.js";
|
|
30
31
|
const FILE_MODE = 0o644;
|
|
31
32
|
const EXEC_MODE = 0o755;
|
|
32
33
|
const MAX_UI_BYTES = 64 * 1024 * 1024;
|
|
@@ -84,7 +85,10 @@ export async function assembleStandaloneSpk(options) {
|
|
|
84
85
|
copyFileSync(options.backgroundWorker, worker);
|
|
85
86
|
chmodSync(worker, EXEC_MODE);
|
|
86
87
|
}
|
|
87
|
-
|
|
88
|
+
const backend = manifest.backend;
|
|
89
|
+
// A daemon is a Go program of the project's backend directory.
|
|
90
|
+
const sdk = backend.kind !== 'binary' ? backend : manifest.daemon ? { kind: 'go', dir: join(manifest.root, 'backend') } : undefined;
|
|
91
|
+
copyStandaloneNotices(manifest.root, manifest.ui.dir, join(staging, 'package'), { go: backend.kind === 'go' || Boolean(manifest.daemon), rust: backend.kind === 'rust', sdk });
|
|
88
92
|
if (manifest.daemon)
|
|
89
93
|
copyFileSync(new URL('../../assets/licenses/TEMPLATE.txt', import.meta.url), join(staging, 'package', 'LICENSES', 'DAEMON-LICENSE.txt'));
|
|
90
94
|
const portal = nativePortal(manifest);
|
|
@@ -104,11 +108,14 @@ export async function assembleStandaloneSpk(options) {
|
|
|
104
108
|
for (const op of manifest.rootOps ?? [])
|
|
105
109
|
writeFile(join(staging, "package", "root", op.name + ".sh"), op.body);
|
|
106
110
|
}
|
|
111
|
+
const hook = manifest.uninstall && uninstallExecProblem(join(staging, "package"), manifest.uninstall.purge);
|
|
112
|
+
if (hook)
|
|
113
|
+
throw new StandaloneBuildError(hook.code, hook.message);
|
|
107
114
|
const packageTgz = join(staging, "package.tgz");
|
|
108
115
|
await createReproducibleTar(join(staging, "package"), packageTgz, { gzip: true });
|
|
109
116
|
rmSync(join(staging, "package"), { recursive: true, force: true });
|
|
110
117
|
const checksum = createHash("md5").update(readFileSync(packageTgz)).digest("hex");
|
|
111
|
-
writeFile(join(staging, "sdk-package.json"), JSON.stringify({ schema: 2, backend: manifest.backend.kind, capabilities: Object.keys(manifest.capabilities).sort(), ...(manifest.entry ? { entry: manifest.entry } : {}) }));
|
|
118
|
+
writeFile(join(staging, "sdk-package.json"), JSON.stringify({ schema: 2, backend: manifest.backend.kind, capabilities: Object.keys(manifest.capabilities).sort(), ...(manifest.entry ? { entry: manifest.entry } : {}), ...(manifest.uninstall ? { uninstall: uninstallRecord(manifest.uninstall, manifest.i18n?.catalogs) } : {}) }));
|
|
112
119
|
writeFile(join(staging, "INFO"), renderInfo(manifest, options.arch, checksum, options.executor?.executorDigest));
|
|
113
120
|
mkdirSync(join(staging, "conf"));
|
|
114
121
|
writeFile(join(staging, "conf", "privilege"), renderNativePrivilege());
|
|
@@ -132,7 +139,11 @@ export async function assembleStandaloneSpk(options) {
|
|
|
132
139
|
...install.static,
|
|
133
140
|
...install.dynamic,
|
|
134
141
|
...(folder ? renderSharedFolderUpgradeWizardFiles(manifest.package, folder) : {}),
|
|
135
|
-
...renderUninstallWizardFiles(manifest.package, "native", {
|
|
142
|
+
...renderUninstallWizardFiles(manifest.package, "native", {
|
|
143
|
+
executor: Boolean(options.executor), portal: Boolean(portal), portalEntry: Boolean(manifest.portal && portalByExecutor(manifest.os_min_ver)),
|
|
144
|
+
rootPurge: Boolean(manifest.uninstall?.purge.root),
|
|
145
|
+
...(manifest.uninstall?.state ? { state: { items: (locale) => stateItemTexts(manifest.uninstall, manifest.i18n?.catalogs, locale) } } : {}),
|
|
146
|
+
}),
|
|
136
147
|
});
|
|
137
148
|
writePackageCenterIcons(staging, icons);
|
|
138
149
|
mkdirSync(dirname(options.outputPath), { recursive: true });
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { CommandRunner } from './build.js';
|
|
2
|
+
export type Toolkit = {
|
|
3
|
+
url: string;
|
|
4
|
+
sha256: string;
|
|
5
|
+
};
|
|
6
|
+
export declare function verifyToolkitArchive(path: string, expected: string): void;
|
|
7
|
+
/** One cache per user, shared by every project and worktree; archives are checked against sha256 on every use. */
|
|
8
|
+
export declare function toolkitCacheDir(env: Record<string, string | undefined>, platform: string, home: string): string;
|
|
9
|
+
/** Extracts the checksum-pinned toolkit archives of one architecture into a fresh folder the caller removes; '' without archives. */
|
|
10
|
+
export declare function prepareToolkit(cwd: string, arch: string, inputs: Toolkit[], run: CommandRunner): Promise<string>;
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/** Checksum-pinned toolkit archives: one private download cache per user, re-extracted from verified bytes on every build. */
|
|
2
|
+
import { createHash } from 'node:crypto';
|
|
3
|
+
import { existsSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, renameSync, rmSync } from 'node:fs';
|
|
4
|
+
import { homedir } from 'node:os';
|
|
5
|
+
import { isAbsolute, join, resolve } from 'node:path';
|
|
6
|
+
function fail(message) { throw Object.assign(new Error(`NATIVE_LAYOUT_INVALID: ${message}`), { code: 'NATIVE_LAYOUT_INVALID' }); }
|
|
7
|
+
const digest = (bytes) => createHash('sha256').update(bytes).digest('hex');
|
|
8
|
+
export function verifyToolkitArchive(path, expected) {
|
|
9
|
+
if (!lstatSync(path).isFile() || digest(readFileSync(path)) !== expected)
|
|
10
|
+
fail('toolkit checksum mismatch');
|
|
11
|
+
}
|
|
12
|
+
/** One cache per user, shared by every project and worktree; archives are checked against sha256 on every use. */
|
|
13
|
+
export function toolkitCacheDir(env, platform, home) {
|
|
14
|
+
if (env.SYNOPKGLAND_CACHE_DIR)
|
|
15
|
+
return resolve(env.SYNOPKGLAND_CACHE_DIR);
|
|
16
|
+
if (platform === 'darwin')
|
|
17
|
+
return join(home, 'Library', 'Caches', 'synopkgland');
|
|
18
|
+
const xdg = env.XDG_CACHE_HOME;
|
|
19
|
+
return join(xdg && isAbsolute(xdg) ? xdg : join(home, '.cache'), 'synopkgland');
|
|
20
|
+
}
|
|
21
|
+
// Concurrent architectures that pin the same archive share one download.
|
|
22
|
+
const downloads = new Map();
|
|
23
|
+
async function download(cwd, cache, input, archive, run) {
|
|
24
|
+
const tmp = mkdtempSync(join(cache, '.download-'));
|
|
25
|
+
try {
|
|
26
|
+
const out = join(tmp, 'archive');
|
|
27
|
+
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, env: { ...process.env } });
|
|
28
|
+
if (r.status !== 0)
|
|
29
|
+
fail('toolkit download failed');
|
|
30
|
+
// Rename is atomic, so another build process sees either no archive or a verified one.
|
|
31
|
+
verifyToolkitArchive(out, input.sha256);
|
|
32
|
+
renameSync(out, archive);
|
|
33
|
+
}
|
|
34
|
+
finally {
|
|
35
|
+
rmSync(tmp, { recursive: true, force: true });
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
/** Extracts the checksum-pinned toolkit archives of one architecture into a fresh folder the caller removes; '' without archives. */
|
|
39
|
+
export async function prepareToolkit(cwd, arch, inputs, run) {
|
|
40
|
+
if (!inputs.length)
|
|
41
|
+
return '';
|
|
42
|
+
const cache = toolkitCacheDir(process.env, process.platform, homedir());
|
|
43
|
+
mkdirSync(cache, { recursive: true, mode: 0o700 });
|
|
44
|
+
if (lstatSync(cache).isSymbolicLink() || (lstatSync(cache).mode & 0o022))
|
|
45
|
+
fail('toolkit cache must be owned and private');
|
|
46
|
+
const archives = [];
|
|
47
|
+
for (const input of inputs) {
|
|
48
|
+
const archive = join(cache, `${input.sha256}.txz`);
|
|
49
|
+
if (!existsSync(archive)) {
|
|
50
|
+
let pending = downloads.get(archive);
|
|
51
|
+
if (!pending) {
|
|
52
|
+
pending = download(cwd, cache, input, archive, run).finally(() => downloads.delete(archive));
|
|
53
|
+
downloads.set(archive, pending);
|
|
54
|
+
}
|
|
55
|
+
await pending;
|
|
56
|
+
}
|
|
57
|
+
verifyToolkitArchive(archive, input.sha256);
|
|
58
|
+
archives.push(archive);
|
|
59
|
+
}
|
|
60
|
+
// Re-extract verified bytes: a package-writable marker never authenticates an extracted tree.
|
|
61
|
+
const stage = mkdtempSync(join(cache, `toolkit-${arch}-`));
|
|
62
|
+
try {
|
|
63
|
+
const flavor = await tarFlavor(cwd, run);
|
|
64
|
+
for (const archive of archives) {
|
|
65
|
+
if (flavor === 'bsdtar')
|
|
66
|
+
await refuseEscapingMembers(cwd, archive, run);
|
|
67
|
+
const r = await run('tar', flavor === 'gnu' ? ['-xJf', archive, '-C', stage, '--keep-directory-symlink'] : ['-xPJf', archive, '-C', stage], { cwd, env: { ...process.env } });
|
|
68
|
+
if (r.status !== 0)
|
|
69
|
+
fail('toolkit extraction failed');
|
|
70
|
+
}
|
|
71
|
+
return stage;
|
|
72
|
+
}
|
|
73
|
+
catch (e) {
|
|
74
|
+
rmSync(stage, { recursive: true, force: true });
|
|
75
|
+
throw e;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* A later toolkit archive may write through a directory symlink an earlier one
|
|
80
|
+
* created. GNU tar keeps such a symlink only with --keep-directory-symlink;
|
|
81
|
+
* bsdtar only with -P, which also allows absolute and .. members, so those are
|
|
82
|
+
* refused from the listing first.
|
|
83
|
+
*/
|
|
84
|
+
async function tarFlavor(cwd, run) {
|
|
85
|
+
const r = await run('tar', ['--version'], { cwd, env: { ...process.env }, capture: true });
|
|
86
|
+
const version = r.status === 0 ? r.stdout ?? '' : '';
|
|
87
|
+
if (/\bGNU tar\b/.test(version))
|
|
88
|
+
return 'gnu';
|
|
89
|
+
if (/\bbsdtar\b/.test(version))
|
|
90
|
+
return 'bsdtar';
|
|
91
|
+
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' });
|
|
92
|
+
}
|
|
93
|
+
async function refuseEscapingMembers(cwd, archive, run) {
|
|
94
|
+
const r = await run('tar', ['-tJf', archive], { cwd, env: { ...process.env }, capture: true });
|
|
95
|
+
if (r.status !== 0)
|
|
96
|
+
fail('toolkit extraction failed');
|
|
97
|
+
for (const member of (r.stdout ?? '').split('\n').filter(Boolean)) {
|
|
98
|
+
if (member.startsWith('/') || member.split('/').includes('..'))
|
|
99
|
+
fail(`toolkit member escapes the toolkit folder: ${member}`);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { StandaloneManifest } from "./manifest.js";
|
|
2
|
+
/** The syslog tag of a failed or timed-out App uninstall hook. */
|
|
3
|
+
export declare const UNINSTALL_SYSLOG_TAG = "synopkgland-uninstall";
|
|
4
|
+
/**
|
|
5
|
+
* `synopkgland_uninstall_hook`: runs `uninstall.purge` as the package account
|
|
6
|
+
* with a fixed environment and the wizard's two answers, within its timeout.
|
|
7
|
+
* A failure is logged and never blocks the uninstall. It sets
|
|
8
|
+
* `synopkgland_purge_args` for the executor purge request.
|
|
9
|
+
*/
|
|
10
|
+
export declare function renderUninstallHook(manifest: StandaloneManifest): string;
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/** The syslog tag of a failed or timed-out App uninstall hook. */
|
|
2
|
+
export const UNINSTALL_SYSLOG_TAG = "synopkgland-uninstall";
|
|
3
|
+
const TRUE_VALUES = "true|True|TRUE|yes|1";
|
|
4
|
+
/** A fixed token in single quotes; only the package's own directories expand. */
|
|
5
|
+
function word(value) {
|
|
6
|
+
return `'${value.replaceAll("'", "'\\''")}'`
|
|
7
|
+
.replaceAll("${SYNOPKG_PKGDEST}", `'"$hook_dest"'`)
|
|
8
|
+
.replaceAll("${SYNOPKG_PKGVAR}", `'"$hook_var"'`);
|
|
9
|
+
}
|
|
10
|
+
function wizardFlag(variable, keys) {
|
|
11
|
+
const values = keys.flatMap(key => [`"\${${key}:-}"`, `"\${wizard_${key}:-}"`]).join(" ");
|
|
12
|
+
return ` for flag in ${values}; do\n case "$flag" in\n ${TRUE_VALUES}) ${variable}=1 ;;\n esac\n done\n`;
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* `synopkgland_uninstall_hook`: runs `uninstall.purge` as the package account
|
|
16
|
+
* with a fixed environment and the wizard's two answers, within its timeout.
|
|
17
|
+
* A failure is logged and never blocks the uninstall. It sets
|
|
18
|
+
* `synopkgland_purge_args` for the executor purge request.
|
|
19
|
+
*/
|
|
20
|
+
export function renderUninstallHook(manifest) {
|
|
21
|
+
const purge = manifest.uninstall?.purge;
|
|
22
|
+
if (!purge)
|
|
23
|
+
return "";
|
|
24
|
+
const pkg = manifest.package;
|
|
25
|
+
const env = [
|
|
26
|
+
"PATH=/usr/bin:/bin", 'HOME="$hook_var"', 'TMPDIR="$hook_var/tmp"',
|
|
27
|
+
...Object.entries(purge.env).map(([key, value]) => `${key}=${word(value)}`),
|
|
28
|
+
'SYNOPKGLAND_DELETE_STATE="$delete_state"', 'SYNOPKGLAND_REMOVE_DATA="$remove_data"',
|
|
29
|
+
].join(" ");
|
|
30
|
+
const argv = [`"$hook_dest"/${word(purge.exec)}`, ...purge.args.map(word)].join(" ");
|
|
31
|
+
const failed = `${pkg} uninstall hook failed: $hook_failure`;
|
|
32
|
+
const logged = `${failed}; output in $hook_var/uninstall-hook.log`;
|
|
33
|
+
return `synopkgland_purge_args=
|
|
34
|
+
synopkgland_uninstall_hook() {
|
|
35
|
+
delete_state=0
|
|
36
|
+
remove_data=0
|
|
37
|
+
${manifest.uninstall?.state ? wizardFlag("delete_state", ["pkgwizard_delete_state"]) : ""}${wizardFlag("remove_data", ["pkgwizard_remove_additional", "delete_app_data"])} [ "$delete_state" -eq 0 ] || synopkgland_purge_args=--delete-state
|
|
38
|
+
hook_dest="\${SYNOPKG_PKGDEST:-/var/packages/${pkg}/target}"
|
|
39
|
+
hook_var="\${SYNOPKG_PKGVAR:-/var/packages/${pkg}/var}"
|
|
40
|
+
mkdir -p "$hook_var/tmp" 2>/dev/null || true
|
|
41
|
+
# Its own process group, so a timeout stops the hook's children too; its
|
|
42
|
+
# output goes to a file, so a lingering child cannot hold DSM's pipe open.
|
|
43
|
+
set -m
|
|
44
|
+
(cd "$hook_var" 2>/dev/null || cd /; exec /usr/bin/env -i ${env} ${argv}) </dev/null >"$hook_var/uninstall-hook.log" 2>&1 &
|
|
45
|
+
hook_pid=$!
|
|
46
|
+
set +m
|
|
47
|
+
hook_waited=0
|
|
48
|
+
while kill -0 "$hook_pid" 2>/dev/null && [ "$hook_waited" -lt ${purge.timeout} ]; do
|
|
49
|
+
sleep 1
|
|
50
|
+
hook_waited=$((hook_waited + 1))
|
|
51
|
+
done
|
|
52
|
+
hook_failure=
|
|
53
|
+
if kill -0 "$hook_pid" 2>/dev/null; then
|
|
54
|
+
kill -TERM -"$hook_pid" 2>/dev/null || kill -TERM "$hook_pid" 2>/dev/null
|
|
55
|
+
sleep 1
|
|
56
|
+
kill -KILL -"$hook_pid" 2>/dev/null || kill -KILL "$hook_pid" 2>/dev/null
|
|
57
|
+
wait "$hook_pid" 2>/dev/null
|
|
58
|
+
hook_failure="timeout after ${purge.timeout} s"
|
|
59
|
+
else
|
|
60
|
+
wait "$hook_pid"
|
|
61
|
+
hook_status=$?
|
|
62
|
+
[ "$hook_status" -eq 0 ] || hook_failure="exit $hook_status"
|
|
63
|
+
fi
|
|
64
|
+
if [ -n "$hook_failure" ]; then
|
|
65
|
+
echo "${logged}" >&2
|
|
66
|
+
logger -t ${UNINSTALL_SYSLOG_TAG} "${logged}" 2>/dev/null || true
|
|
67
|
+
fi
|
|
68
|
+
return 0
|
|
69
|
+
}
|
|
70
|
+
`;
|
|
71
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { type Catalog, type Catalogs, type Locale } from "./i18n.js";
|
|
2
|
+
import type { StandaloneError } from "./manifest.js";
|
|
3
|
+
import { type RootOp } from "./root-ops.js";
|
|
4
|
+
/**
|
|
5
|
+
* `uninstall`: the App's own hook on a real uninstall, run as the package
|
|
6
|
+
* account before the executor purge, and the state it deletes when the
|
|
7
|
+
* administrator ticks the uninstall wizard's "delete state" box.
|
|
8
|
+
*/
|
|
9
|
+
export interface Uninstall {
|
|
10
|
+
purge: UninstallPurge;
|
|
11
|
+
state?: {
|
|
12
|
+
items: Array<{
|
|
13
|
+
key: string;
|
|
14
|
+
}>;
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
export interface UninstallPurge {
|
|
18
|
+
/** A package-relative executable, outside executor/ and root/. */
|
|
19
|
+
exec: string;
|
|
20
|
+
args: string[];
|
|
21
|
+
env: Record<string, string>;
|
|
22
|
+
/** Seconds, 2..120. */
|
|
23
|
+
timeout: number;
|
|
24
|
+
/** A root/<name>.sh the executor runs at uninstall, only while its approval is live. */
|
|
25
|
+
root?: string;
|
|
26
|
+
}
|
|
27
|
+
/** The two flags the generated preuninst hands the hook. */
|
|
28
|
+
export declare const UNINSTALL_FLAGS: readonly ["SYNOPKGLAND_DELETE_STATE", "SYNOPKGLAND_REMOVE_DATA"];
|
|
29
|
+
export interface UninstallContext {
|
|
30
|
+
rootOps: boolean;
|
|
31
|
+
ops?: RootOp[];
|
|
32
|
+
/** The App's English catalog; each state item needs `uninstall.state.<key>`. */
|
|
33
|
+
en?: Catalog;
|
|
34
|
+
}
|
|
35
|
+
export declare function stateTextKey(key: string): string;
|
|
36
|
+
export declare function readUninstall(value: unknown, context: UninstallContext, fail: (error: StandaloneError) => void): Uninstall | undefined;
|
|
37
|
+
/** The hook in the staged package: a regular executable that does not try to become root. */
|
|
38
|
+
export declare function uninstallExecProblem(packageDir: string, purge: UninstallPurge): StandaloneError | undefined;
|
|
39
|
+
/** Each state item's text in this locale's catalog, English when the App does not ship it. */
|
|
40
|
+
export declare function stateItemTexts(uninstall: Uninstall, catalogs: Catalogs | undefined, locale: Locale): string[];
|
|
41
|
+
/**
|
|
42
|
+
* sdk-package.json's `uninstall`: the hook and the state item texts per
|
|
43
|
+
* locale, from which inspect renders the same preuninst and wizard again.
|
|
44
|
+
*/
|
|
45
|
+
export interface UninstallRecord extends Uninstall {
|
|
46
|
+
texts?: Catalogs;
|
|
47
|
+
}
|
|
48
|
+
export declare function uninstallRecord(uninstall: Uninstall, catalogs: Catalogs | undefined): UninstallRecord;
|
|
49
|
+
/** The record read back and checked as the manifest's `uninstall` is; undefined when it does not hold. */
|
|
50
|
+
export declare function readUninstallRecord(value: unknown, rootOps: boolean): {
|
|
51
|
+
uninstall: Uninstall;
|
|
52
|
+
catalogs?: Catalogs;
|
|
53
|
+
} | undefined;
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import { lstatSync, readFileSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { LOCALES } from "./i18n.js";
|
|
4
|
+
import { ROOT_SCRIPT_HINTS } from "./root-ops.js";
|
|
5
|
+
import { envKey, relativeExec, token } from "./daemon.js";
|
|
6
|
+
/** The two flags the generated preuninst hands the hook. */
|
|
7
|
+
export const UNINSTALL_FLAGS = ["SYNOPKGLAND_DELETE_STATE", "SYNOPKGLAND_REMOVE_DATA"];
|
|
8
|
+
const FIELDS = new Set(["purge", "state"]);
|
|
9
|
+
const PURGE_FIELDS = new Set(["exec", "args", "env", "timeout", "root"]);
|
|
10
|
+
const STATE_KEY = /^[a-z][A-Za-z0-9_-]{0,31}$/;
|
|
11
|
+
const record = (v) => Boolean(v) && typeof v === "object" && !Array.isArray(v);
|
|
12
|
+
export function stateTextKey(key) {
|
|
13
|
+
return `uninstall.state.${key}`;
|
|
14
|
+
}
|
|
15
|
+
export function readUninstall(value, context, fail) {
|
|
16
|
+
if (value === undefined)
|
|
17
|
+
return undefined;
|
|
18
|
+
const invalid = () => {
|
|
19
|
+
fail({ code: "UNINSTALL_INVALID", message: "uninstall 只接受 purge {exec, args, env, timeout, root} 與 state {items: [{key}]};exec 須是套件內固定執行檔(不可在 executor/ 或 root/),timeout 為 2–120 秒整數。" });
|
|
20
|
+
return undefined;
|
|
21
|
+
};
|
|
22
|
+
if (!record(value) || Object.keys(value).some(k => !FIELDS.has(k)))
|
|
23
|
+
return invalid();
|
|
24
|
+
if (value.purge === undefined) {
|
|
25
|
+
if (value.state === undefined)
|
|
26
|
+
return invalid();
|
|
27
|
+
fail({ code: "UNINSTALL_STATE_REQUIRES_PURGE", message: "uninstall.state 需要 uninstall.purge:只有 App 自己知道狀態存在哪裡。" });
|
|
28
|
+
return undefined;
|
|
29
|
+
}
|
|
30
|
+
const purge = readPurge(value.purge, context, fail);
|
|
31
|
+
if (purge === "invalid")
|
|
32
|
+
return invalid();
|
|
33
|
+
const state = value.state === undefined ? undefined : readState(value.state);
|
|
34
|
+
if (state === "invalid")
|
|
35
|
+
return invalid();
|
|
36
|
+
const missing = (state?.items ?? []).map(item => stateTextKey(item.key)).filter(key => context.en?.[key] === undefined);
|
|
37
|
+
if (missing.length) {
|
|
38
|
+
fail({ code: "UNINSTALL_STATE_TEXT_MISSING", message: `uninstall.state 的項目需要 i18n en.json 文字:${missing.join(", ")}。` });
|
|
39
|
+
return undefined;
|
|
40
|
+
}
|
|
41
|
+
return purge && { purge, ...(state ? { state } : {}) };
|
|
42
|
+
}
|
|
43
|
+
function readPurge(value, context, fail) {
|
|
44
|
+
if (!record(value) || Object.keys(value).some(k => !PURGE_FIELDS.has(k)))
|
|
45
|
+
return "invalid";
|
|
46
|
+
const top = typeof value.exec === "string" ? value.exec.split("/")[0] : "";
|
|
47
|
+
if (!relativeExec(value.exec) || top === "executor" || top === "root")
|
|
48
|
+
return "invalid";
|
|
49
|
+
const args = value.args ?? [];
|
|
50
|
+
if (!Array.isArray(args) || args.length > 64 || !args.every(token))
|
|
51
|
+
return "invalid";
|
|
52
|
+
const env = value.env ?? {};
|
|
53
|
+
if (!record(env) || Object.keys(env).length > 64
|
|
54
|
+
|| Object.entries(env).some(([k, v]) => !envKey(k) || k.startsWith("SYNOPKGLAND_") || !token(v)))
|
|
55
|
+
return "invalid";
|
|
56
|
+
const timeout = value.timeout ?? 60;
|
|
57
|
+
if (!Number.isInteger(timeout) || timeout < 2 || timeout > 120)
|
|
58
|
+
return "invalid";
|
|
59
|
+
const root = value.root;
|
|
60
|
+
if (root !== undefined) {
|
|
61
|
+
if (typeof root !== "string")
|
|
62
|
+
return "invalid";
|
|
63
|
+
if (!context.rootOps) {
|
|
64
|
+
fail({ code: "UNINSTALL_ROOT_REQUIRES_ROOT_OPS", message: "uninstall.purge.root 需要 capabilities.rootOps: true。" });
|
|
65
|
+
return undefined;
|
|
66
|
+
}
|
|
67
|
+
if (context.ops && !context.ops.some(op => op.name === root)) {
|
|
68
|
+
fail({ code: "UNINSTALL_ROOT_OP_MISSING", message: `uninstall.purge.root「${root}」需要 root/${root}.sh。` });
|
|
69
|
+
return undefined;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
return {
|
|
73
|
+
exec: value.exec,
|
|
74
|
+
args: args,
|
|
75
|
+
env: Object.fromEntries(Object.keys(env).sort().map(key => [key, env[key]])),
|
|
76
|
+
timeout: timeout,
|
|
77
|
+
...(root === undefined ? {} : { root }),
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
function readState(value) {
|
|
81
|
+
if (!record(value) || Object.keys(value).some(k => k !== "items") || !Array.isArray(value.items))
|
|
82
|
+
return "invalid";
|
|
83
|
+
const items = value.items;
|
|
84
|
+
if (items.length === 0 || items.length > 16)
|
|
85
|
+
return "invalid";
|
|
86
|
+
const keys = items.map(item => record(item) && Object.keys(item).length === 1 && typeof item.key === "string" && STATE_KEY.test(item.key) ? item.key : undefined);
|
|
87
|
+
if (keys.some(key => key === undefined) || new Set(keys).size !== keys.length)
|
|
88
|
+
return "invalid";
|
|
89
|
+
return { items: keys.map(key => ({ key: key })) };
|
|
90
|
+
}
|
|
91
|
+
/** The hook in the staged package: a regular executable that does not try to become root. */
|
|
92
|
+
export function uninstallExecProblem(packageDir, purge) {
|
|
93
|
+
const path = join(packageDir, purge.exec);
|
|
94
|
+
let stat;
|
|
95
|
+
try {
|
|
96
|
+
stat = lstatSync(path);
|
|
97
|
+
}
|
|
98
|
+
catch {
|
|
99
|
+
stat = undefined;
|
|
100
|
+
}
|
|
101
|
+
if (!stat?.isFile() || (stat.mode & 0o111) === 0) {
|
|
102
|
+
return { code: "UNINSTALL_EXEC_MISSING", message: `uninstall.purge.exec「${purge.exec}」不是套件內可執行的一般檔案。` };
|
|
103
|
+
}
|
|
104
|
+
const text = readFileSync(path).toString("latin1");
|
|
105
|
+
if (text.startsWith("#!") && ROOT_SCRIPT_HINTS.some(hint => hint.test(text))) {
|
|
106
|
+
return { code: "UNINSTALL_EXEC_UNSAFE", message: `uninstall.purge.exec「${purge.exec}」嘗試取得 root(sudo、chown root 或 setuid);root 只能經 uninstall.purge.root。` };
|
|
107
|
+
}
|
|
108
|
+
return undefined;
|
|
109
|
+
}
|
|
110
|
+
/** Each state item's text in this locale's catalog, English when the App does not ship it. */
|
|
111
|
+
export function stateItemTexts(uninstall, catalogs, locale) {
|
|
112
|
+
return (uninstall.state?.items ?? []).map(({ key }) => catalogs?.[locale]?.[stateTextKey(key)] ?? catalogs?.en[stateTextKey(key)] ?? key);
|
|
113
|
+
}
|
|
114
|
+
export function uninstallRecord(uninstall, catalogs) {
|
|
115
|
+
if (!uninstall.state)
|
|
116
|
+
return uninstall;
|
|
117
|
+
const keys = uninstall.state.items.map(item => stateTextKey(item.key));
|
|
118
|
+
const texts = Object.fromEntries(LOCALES.flatMap(locale => {
|
|
119
|
+
const catalog = catalogs?.[locale];
|
|
120
|
+
const found = Object.fromEntries(keys.filter(key => catalog?.[key] !== undefined).map(key => [key, catalog[key]]));
|
|
121
|
+
return Object.keys(found).length ? [[locale, found]] : [];
|
|
122
|
+
}));
|
|
123
|
+
return { ...uninstall, texts: texts };
|
|
124
|
+
}
|
|
125
|
+
/** The record read back and checked as the manifest's `uninstall` is; undefined when it does not hold. */
|
|
126
|
+
export function readUninstallRecord(value, rootOps) {
|
|
127
|
+
if (!record(value))
|
|
128
|
+
return undefined;
|
|
129
|
+
const { texts, ...rest } = value;
|
|
130
|
+
if (texts !== undefined && (!record(texts) || Object.entries(texts).some(([locale, catalog]) => !LOCALES.includes(locale) || !record(catalog) || Object.values(catalog).some(text => typeof text !== "string"))))
|
|
131
|
+
return undefined;
|
|
132
|
+
let failed = false;
|
|
133
|
+
const catalogs = texts;
|
|
134
|
+
const uninstall = readUninstall(rest, { rootOps, en: catalogs?.en }, () => { failed = true; });
|
|
135
|
+
return uninstall && !failed ? { uninstall, ...(catalogs ? { catalogs } : {}) } : undefined;
|
|
136
|
+
}
|
package/dist/types.d.ts
CHANGED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sets top-level keys of a YAML mapping in its source text: each named key's
|
|
3
|
+
* entry is rewritten, a missing one is appended, and every other line,
|
|
4
|
+
* comments and formatting included, stays as written.
|
|
5
|
+
*/
|
|
6
|
+
export declare function setTopLevel(text: string, values: Record<string, unknown>): string;
|