@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.
Files changed (73) hide show
  1. package/CHANGELOG.md +94 -0
  2. package/README.md +1 -1
  3. package/assets/licenses/MUSL-COPYRIGHT +193 -0
  4. package/assets/licenses/RUST-COPYRIGHT-library.html +5556 -0
  5. package/dist/add.js +29 -29
  6. package/dist/build-spk/dsm-version.d.ts +14 -4
  7. package/dist/build-spk/dsm-version.js +17 -5
  8. package/dist/build-spk/notices.d.ts +8 -1
  9. package/dist/build-spk/notices.js +13 -4
  10. package/dist/build-spk/uninstall-wizard.d.ts +11 -0
  11. package/dist/build-spk/uninstall-wizard.js +16 -3
  12. package/dist/build-spk/wizard-i18n.d.ts +1 -1
  13. package/dist/build-spk/wizard-i18n.js +1 -1
  14. package/dist/build-spk/wizard-messages.d.ts +44 -0
  15. package/dist/build-spk/wizard-messages.js +44 -0
  16. package/dist/capability-overlays.d.ts +10 -5
  17. package/dist/capability-overlays.js +32 -10
  18. package/dist/cli.js +5 -5
  19. package/dist/deploy/plan.d.ts +1 -1
  20. package/dist/deploy/plan.js +1 -1
  21. package/dist/error-registry.js +46 -4
  22. package/dist/info.js +4 -3
  23. package/dist/init.js +53 -28
  24. package/dist/portal/alias.d.ts +8 -0
  25. package/dist/portal/alias.js +16 -0
  26. package/dist/runtime/executor-build.d.ts +1 -1
  27. package/dist/runtime/executor-build.js +2 -1
  28. package/dist/runtime/executor-digests.json +22 -0
  29. package/dist/runtime/executor-policy.d.ts +15 -0
  30. package/dist/runtime/executor-policy.js +32 -6
  31. package/dist/runtime/sdk-source.d.ts +14 -2
  32. package/dist/runtime/sdk-source.js +39 -4
  33. package/dist/runtime/uninstall-cleanup.d.ts +5 -1
  34. package/dist/runtime/uninstall-cleanup.js +11 -1
  35. package/dist/standalone/api-permissions.d.ts +4 -2
  36. package/dist/standalone/api-permissions.js +6 -5
  37. package/dist/standalone/binary.d.ts +3 -2
  38. package/dist/standalone/binary.js +17 -5
  39. package/dist/standalone/build.js +16 -12
  40. package/dist/standalone/daemon.d.ts +6 -0
  41. package/dist/standalone/daemon.js +8 -4
  42. package/dist/standalone/dsm-window.js +16 -3
  43. package/dist/standalone/inspect-spk.d.ts +1 -1
  44. package/dist/standalone/inspect-spk.js +46 -13
  45. package/dist/standalone/lifecycle.js +38 -4
  46. package/dist/standalone/manifest.d.ts +12 -0
  47. package/dist/standalone/manifest.js +62 -20
  48. package/dist/standalone/native-layout.d.ts +4 -7
  49. package/dist/standalone/native-layout.js +41 -102
  50. package/dist/standalone/native-root-window.d.ts +2 -1
  51. package/dist/standalone/native-root-window.js +15 -5
  52. package/dist/standalone/portal-window.d.ts +4 -2
  53. package/dist/standalone/portal-window.js +54 -12
  54. package/dist/standalone/portal.d.ts +4 -1
  55. package/dist/standalone/portal.js +8 -2
  56. package/dist/standalone/root-ops.d.ts +2 -0
  57. package/dist/standalone/root-ops.js +2 -0
  58. package/dist/standalone/rust-build.d.ts +35 -0
  59. package/dist/standalone/rust-build.js +127 -0
  60. package/dist/standalone/sdk-check.d.ts +5 -3
  61. package/dist/standalone/sdk-check.js +21 -8
  62. package/dist/standalone/spk.js +15 -3
  63. package/dist/standalone/toolkit.d.ts +10 -0
  64. package/dist/standalone/toolkit.js +101 -0
  65. package/dist/standalone/uninstall-hook.d.ts +10 -0
  66. package/dist/standalone/uninstall-hook.js +71 -0
  67. package/dist/standalone/uninstall.d.ts +53 -0
  68. package/dist/standalone/uninstall.js +136 -0
  69. package/dist/types.d.ts +1 -1
  70. package/dist/yaml-edit.d.ts +6 -0
  71. package/dist/yaml-edit.js +31 -0
  72. package/package.json +2 -2
  73. 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, PORTAL_MIN_DSM, portalMinDsmMessage } from "../build-spk/dsm-version.js";
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 { SDK_MODULE, sdkRequirement } from "../runtime/sdk-source.js";
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?.kind === "go") {
138
+ if (backend && backend.kind !== "binary") {
136
139
  try {
137
- permissions = readApiPermissions(backend.dir, capabilities);
140
+ permissions = readApiPermissions(backend, capabilities);
138
141
  }
139
142
  catch (error) {
140
- // The SDK's own routes come from its module; a failed download is not a policy problem.
141
- const download = /^\[SDK_DOWNLOAD_FAILED\] ([\s\S]*)$/.exec(error.message);
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/internal/api/permissions.json must define the centralized API policy: ${String(error)}`);
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?.kind === "go") {
173
- for (const problem of sdkProblems(backend.dir, ui?.build?.cwd, Boolean(capabilities.delegate || capabilities.rootOps)))
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, PORTAL_MIN_DSM)) {
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 main = backend.kind === "go" ? join(backend.dir, backend.main, "main.go") : "";
239
- if (backend.kind !== "go" || !sdkRequirement(backend.dir)
240
- || !existsSync(main) || !readFileSync(main, "utf8").includes("dsm.NewEntryGate(")) {
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:backend/go.mod 要 require ${SDK_MODULE},並在 ${shown} 以 dsm.NewEntryGate 包住 authenticator。`);
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, renameSync, rmSync, writeFileSync } from 'node:fs';
5
- import { homedir, tmpdir } from 'node:os';
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 toolkitFor(m, arch, run);
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, then
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, then
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 = 2000, RUN_LIMIT_MS = 10 * 60 * 1000;
22
- var byOp = {}, running = {};
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 find(task).then(function (found) {
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 status or
16
- * reject `{code}`; `openControlPanel()` opens Login Portal > Applications.
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
  */