@synopackageland/cli 0.3.7 → 0.3.8

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.
@@ -185,6 +185,7 @@ export async function buildSpk(options) {
185
185
  const resourceProjectDir = join(staging, "package", "compose");
186
186
  mkdirSync(resourceProjectDir, { recursive: true });
187
187
  const dsmUserParam = collectInstallParameters(contract).find((p) => p.param.type === "dsm_user");
188
+ let composeContent;
188
189
  if (dsmUserParam || rwEligibility?.headless === true || hasOidcCompat(contract)) {
189
190
  const rawYaml = readFileSync(options.project.composePath, "utf8");
190
191
  const doc = parseDocument(rawYaml);
@@ -222,16 +223,17 @@ export async function buildSpk(options) {
222
223
  if (rwEligibility?.headless === true) {
223
224
  injectHeadlessProbeVolume(doc);
224
225
  }
225
- let composeContent = doc.toString();
226
+ composeContent = doc.toString();
226
227
  if (dsmUserParam) {
227
228
  const escaped = `\\$\\{${dsmUserParam.name}_UID\\}:\\$\\{${dsmUserParam.name}_GID\\}`;
228
229
  composeContent = composeContent.replace(new RegExp(`^([ \\t]*)user:\\s*"${escaped}"`, "gm"), `$1user: "\${${dsmUserParam.name}_UID}:\${${dsmUserParam.name}_GID}"\n$1# synopkgland:group_add ${dsmUserParam.name}`);
229
230
  }
230
- writeFileSync(join(resourceProjectDir, "docker-compose.yml"), composeContent, "utf8");
231
231
  }
232
232
  else {
233
- copyFileSync(options.project.composePath, join(resourceProjectDir, "docker-compose.yml"));
233
+ composeContent = readFileSync(options.project.composePath, "utf8");
234
234
  }
235
+ composeContent = injectPosixAllSharesMarkers(composeContent, contract);
236
+ writeFileSync(join(resourceProjectDir, "docker-compose.yml"), composeContent, "utf8");
235
237
  chmodSync(join(resourceProjectDir, "docker-compose.yml"), FILE_MODE);
236
238
  if (rwEligibility?.headless === true) {
237
239
  const probePayloadDir = join(resourceProjectDir, ".synopkgland");
@@ -354,6 +356,7 @@ function renderPrivilege() {
354
356
  }
355
357
  const SHARED_FOLDER_MOUNT_CONSENT = "此 App 會用你在這台 NAS 選的資料夾;整顆應用都看得到。Shared Folder Mount 是整個 App Instance 的 container access:能打開這個 App 的使用者,容器裡的程式都看得到該 Share,即使該使用者在 File Station 看不到。這不是逐使用者 DSM ACL。要依登入者權限讀寫 NAS 檔案,必須另外核准廣域 files.* 並走 File Gateway。";
356
358
  const HOMES_MOUNT_CONSENT = "此 App 會使用這台 NAS 已啟用的 User Home;整顆應用的容器都看得到,容器路徑由 Compose 決定。這不是逐使用者 DSM ACL;要依登入者權限讀寫 NAS 檔案,必須另外核准廣域 files.* 並走 File Gateway。";
359
+ const POSIX_ALL_SHARES_MAX_WARNING = "掛載這台 NAS 目前列出的每一個共用資料夾。容器內可讀(或讀寫,依 Compose 模式)所有這些資料夾的檔案。安裝後新建的共用資料夾不會自動加入。由管理員決定是否安裝。";
357
360
  const DSM_USER_RUN_AS_CONSENT = "寫進 Share 的新檔在 File Station 顯示為你選的那位 DSM 使用者。點開此 App 的人共用這個磁碟身分,不是逐登入 ACL。";
358
361
  const DSM_USER_UPGRADE_CONSENT = "升級時更換 Container Run-as 不會 chown Share 上的舊檔;只有升級後寫入的新檔才會使用新的磁碟身分。";
359
362
  const SHARED_FOLDER_RW_UPGRADE_CONSENT = "此版本把 Share 掛載改為讀寫;容器可讀取並寫入你選的 Share,不再是唯讀。";
@@ -451,6 +454,29 @@ function injectHeadlessProbeVolume(doc) {
451
454
  }
452
455
  doc.setIn(["services", serviceName, "volumes"], volumes);
453
456
  }
457
+ function injectPosixAllSharesMarkers(composeContent, contract) {
458
+ const names = collectInstallParameters(contract)
459
+ .filter(({ param }) => param.type === "posix_all_shares")
460
+ .map(({ name }) => name);
461
+ if (names.length === 0) {
462
+ return composeContent;
463
+ }
464
+ const declared = new Set(names);
465
+ const lines = composeContent.split("\n");
466
+ const output = [];
467
+ for (const line of lines) {
468
+ const match = line.match(/^(\s*)-\s*["']?\$\{([A-Za-z_][A-Za-z0-9_]*)\}:/);
469
+ const name = match?.[2];
470
+ if (match && name && declared.has(name)) {
471
+ const marker = `# synopkgland:posix_all_shares ${name}`;
472
+ if (output.at(-1)?.trim() !== marker) {
473
+ output.push(`${match[1]}${marker}`);
474
+ }
475
+ }
476
+ output.push(line);
477
+ }
478
+ return output.join("\n");
479
+ }
454
480
  const PRIVILEGE_MAX_WARNING = "此 App 的容器有高權限,接近這台 NAS 的 root、幾乎整台機器。是否安裝由你這位管理員決定。";
455
481
  function renderInstallWizard(contract, compose) {
456
482
  const items = [];
@@ -557,6 +583,39 @@ function renderInstallWizard(contract, compose) {
557
583
  });
558
584
  continue;
559
585
  }
586
+ if (param.type === "posix_all_shares") {
587
+ items.push({
588
+ type: "textblock",
589
+ desc: POSIX_ALL_SHARES_MAX_WARNING,
590
+ });
591
+ items.push({
592
+ type: "combobox",
593
+ multiSelect: true,
594
+ desc: `POSIX all shares: ${name}`,
595
+ subitems: [{
596
+ key: name,
597
+ desc: name,
598
+ displayField: "name",
599
+ valueField: "name",
600
+ multiSelect: true,
601
+ editable: false,
602
+ forceSelection: true,
603
+ autoSelect: false,
604
+ mode: "remote",
605
+ api_store: {
606
+ api: "SYNO.Core.Share",
607
+ method: "list",
608
+ version: 1,
609
+ baseParams: { limit: -1, offset: 0 },
610
+ root: "shares",
611
+ idProperty: "name",
612
+ fields: ["name"],
613
+ },
614
+ validator: { allowBlank: false },
615
+ }],
616
+ });
617
+ continue;
618
+ }
560
619
  if (param.type === "dsm_user") {
561
620
  items.push({
562
621
  type: "combobox",
@@ -654,6 +713,7 @@ function renderDynamicPreinstMaterializer(contract) {
654
713
  const allParams = collectInstallParameters(contract);
655
714
  const scalarParams = allParams.filter(({ param }) => param.type === "string" || param.type === "boolean");
656
715
  const sharedFolderParams = allParams.filter(({ param }) => param.type === "shared_folder");
716
+ const posixAllSharesParams = allParams.filter(({ param }) => param.type === "posix_all_shares");
657
717
  const dsmUserParam = allParams.find(({ param }) => param.type === "dsm_user");
658
718
  const lines = [
659
719
  "#!/bin/sh",
@@ -716,9 +776,12 @@ function renderDynamicPreinstMaterializer(contract) {
716
776
  " esac",
717
777
  "}",
718
778
  ];
719
- if (sharedFolderParams.length > 0) {
779
+ if (sharedFolderParams.length > 0 || posixAllSharesParams.length > 0) {
720
780
  lines.push("resolve_shared_folder() {", " share_name=\"$1\"", " case \"$share_name\" in", " */*|*..*) die \"invalid share name $share_name\" ;;", " '') die \"share name is empty\" ;;", " esac", " mounts_file=\"${D54_MOUNTS_FILE:-/proc/mounts}\"", " if [ ! -r \"$mounts_file\" ] && [ -r \"/etc/mtab\" ]; then", " mounts_file=\"/etc/mtab\"", " fi", " if [ ! -r \"$mounts_file\" ]; then", " die \"cannot read mount points from $mounts_file\"", " fi", " vols=\"$(awk '$2 ~ /^\\/volume[0-9]+$/ {print $2}' \"$mounts_file\" | sort -u)\"", ' if [ -z "$vols" ]; then', " vols=\"$(awk '{print $2}' \"$mounts_file\" | sort -u)\"", " fi", " matched=\"\"", " count=0", " for v in $vols; do", " if [ -d \"$v/$share_name\" ]; then", " matched=\"$v/$share_name\"", " count=$((count + 1))", " fi", " done", " if [ \"$count\" -eq 0 ]; then", " die \"share $share_name not found on any volume\"", " elif [ \"$count\" -gt 1 ]; then", " die \"share $share_name matched multiple volumes\"", " fi", " printf '%s' \"$matched\"", "}");
721
781
  }
782
+ if (posixAllSharesParams.length > 0) {
783
+ lines.push("normalize_posix_all_shares() {", " value=\"$1\"", " case \"$value\" in", " \\[*\\]) value=\"$(printf '%s' \"$value\" | sed 's/^[[:space:]]*\\[//; s/\\][[:space:]]*$//; s/\"//g')\" ;;", " esac", " printf '%s' \"$value\" | awk -F, '{ for (i = 1; i <= NF; i++) { gsub(/^[ \\t]+|[ \\t]+$/, \"\", $i); if ($i != \"\") print $i } }'", "}", "resolve_posix_all_shares() {", " raw_list=\"$1\"", " normalized=\"$(normalize_posix_all_shares \"$raw_list\")\"", " [ -n \"$normalized\" ] || die \"POSIX_ALL_SHARES_EMPTY\"", " names=\"\"", " paths=\"\"", " count=0", " while IFS= read -r share_name; do", " case \"$share_name\" in", " */*|*..*|@*|'') die \"invalid Share name $share_name\" ;;", " *[[:cntrl:]]*) die \"invalid Share name $share_name\" ;;", " esac", " share_path=\"$(resolve_shared_folder \"$share_name\")\"", " names=\"${names}${names:+,}${share_name}\"", " paths=\"${paths}${paths:+,}${share_path}\"", " count=$((count + 1))", " done <<EOF", "$normalized", "EOF", " [ \"$count\" -gt 0 ] || die \"POSIX_ALL_SHARES_EMPTY\"", " printf '%s\\n%s\\n%s' \"$names\" \"$paths\" \"$count\"", "}", "rewrite_posix_all_shares() {", " p_name=\"$1\"", " share_names=\"$2\"", " share_paths=\"$3\"", " compose_file=\"$4\"", " [ -f \"$compose_file\" ] || die \"compose file missing at $compose_file\"", " [ ! -L \"$compose_file\" ] || die \"compose file is a symlink\"", " tmp_compose=\"$(mktemp \"${compose_file}.tmp.XXXXXX\")\"", " awk -v param=\"$p_name\" -v share_names=\"$share_names\" -v share_paths=\"$share_paths\" '", " BEGIN {", " name_count = split(share_names, names, \",\")", " path_count = split(share_paths, paths, \",\")", " marker = \"# synopkgland:posix_all_shares \" param", " pending = 0", " single_quote = sprintf(\"%c\", 39)", " }", " {", " if (index($0, marker) > 0 && $0 ~ /^[ \\t]*# synopkgland:posix_all_shares /) {", " print $0", " pending = 1", " next", " }", " if (!pending) { print $0; next }", " pending = 0", " line = $0", " indent = line", " sub(/[^ \\t].*$/, \"\", indent)", " entry = line", " sub(/^[ \\t]*-[ \\t]*/, \"\", entry)", " if (entry ~ /^\".*\"$/) entry = substr(entry, 2, length(entry) - 2)", " if (length(entry) >= 2 && substr(entry, 1, 1) == single_quote && substr(entry, length(entry), 1) == single_quote) entry = substr(entry, 2, length(entry) - 2)", " split(entry, fields, \":\")", " if (fields[1] != \"${\" param \"}\" || fields[2] == \"\") { print $0; next }", " mode = \"\"", " if (length(fields) >= 3) mode = \":\" fields[3]", " for (i = 1; i <= name_count && i <= path_count; i++) {", " print indent \"- \" paths[i] \":\" fields[2] \"/\" names[i] mode", " }", " }", " ' \"$compose_file\" > \"$tmp_compose\" || die \"failed to expand POSIX all-shares in $compose_file\"", " mv \"$tmp_compose\" \"$compose_file\" || die \"failed to replace $compose_file\"", "}");
784
+ }
722
785
  if (dsmUserParam) {
723
786
  lines.push("resolve_dsm_user() {", " user_name=\"$1\"", " case \"$user_name\" in", " */*|*..*|*[[:cntrl:]]*) die \"invalid user name $user_name\" ;;", " '') die \"user name is empty\" ;;", " esac", " u_uid=\"$(id -u -- \"$user_name\" 2>/dev/null || true)\"", " case \"$u_uid\" in", " ''|*[!0-9]*) die \"resolved uid for $user_name is not a valid integer\" ;;", " 0) die \"resolved uid for $user_name cannot be 0 (root)\" ;;", " esac", " u_gid=\"$(id -g -- \"$user_name\" 2>/dev/null || true)\"", " case \"$u_gid\" in", " ''|*[!0-9]*) die \"resolved gid for $user_name is not a valid integer\" ;;", " esac", " # id -G returns all group ids including primary", " raw_groups=\"$(id -G -- \"$user_name\" 2>/dev/null || true)\"", " # filter out primary gid, deduplicate, keep numeric only", " supp_groups=\"\"", " for g in $raw_groups; do", " case \"$g\" in", " ''|*[!0-9]*) continue ;;", " esac", " if [ \"$g\" != \"$u_gid\" ]; then", " already=0", " for sg in $supp_groups; do", " if [ \"$sg\" = \"$g\" ]; then", " already=1", " break", " fi", " done", " if [ \"$already\" -eq 0 ]; then", " supp_groups=\"${supp_groups}${supp_groups:+ }$g\"", " fi", " fi", " done", " printf '%s %s %s' \"$u_uid\" \"$u_gid\" \"$supp_groups\"", "}", "rewrite_group_add() {", " p_name=\"$1\"", " g_list=\"$2\"", " compose_file=\"$3\"", " [ -f \"$compose_file\" ] || die \"compose file missing at $compose_file\"", " [ ! -L \"$compose_file\" ] || die \"compose file is a symlink\"", " tmp_compose=\"$(mktemp \"${compose_file}.tmp.XXXXXX\")\"", " awk -v param=\"$p_name\" -v g_list=\"$g_list\" '", " BEGIN {", " n = split(g_list, g_arr, \" \")", " }", " {", " regex = \"^[ \\t]*# synopkgland:group_add \" param \"$\"", " if ($0 ~ regex) {", " match($0, /^[ \\t]*/)", " indent = substr($0, RSTART, RLENGTH)", " if (n > 0) {", " print indent \"group_add:\"", " for (i = 1; i <= n; i++) {", " print indent \" - ${\" param \"_GROUP_\" i \"}\"", " }", " }", " } else {", " print $0", " }", " }", " ' \"$compose_file\" > \"$tmp_compose\" || die \"failed to rewrite group_add in $compose_file\"", " mv \"$tmp_compose\" \"$compose_file\" || die \"failed to replace $compose_file\"", "}");
724
787
  }
@@ -752,6 +815,16 @@ function renderDynamicPreinstMaterializer(contract) {
752
815
  lines.push(` if [ "\${${name}+x}" = x ]; then`, ` ${rawVar}="\${${name}}"`, ' elif [ "$STATUS" = "UPGRADE" ] && [ -f "$CANONICAL_ENV" ] && [ ! -L "$CANONICAL_ENV" ] && ' +
753
816
  `${rawVar}="$(read_env_value "$CANONICAL_ENV" "${name}")"; then`, " :", " else", ` die "missing required shared folder parameter ${name}"`, " fi", ` case "$${rawVar}" in`, " /*) " + pathVar + "=\"$" + rawVar + "\" ;;", " *) " + pathVar + "=\"$(resolve_shared_folder \"$" + rawVar + "\")\" ;;", " esac");
754
817
  }
818
+ const posixVars = [];
819
+ for (const { name } of posixAllSharesParams) {
820
+ const rawVar = "D54_RAW_POSIX_" + name;
821
+ const namesVar = "D54_POSIX_NAMES_" + name;
822
+ const pathsVar = "D54_POSIX_PATHS_" + name;
823
+ const countVar = "D54_POSIX_COUNT_" + name;
824
+ posixVars.push({ name, namesVar, pathsVar, countVar });
825
+ lines.push(` if [ "\${${name}+x}" = x ]; then`, ` ${rawVar}="\${${name}}"`, ' elif [ "$STATUS" = "UPGRADE" ] && [ -f "$CANONICAL_ENV" ] && [ ! -L "$CANONICAL_ENV" ] && ' +
826
+ `${rawVar}="$(read_env_value "$CANONICAL_ENV" "${name}")"; then`, " :", " else", ` die "missing required POSIX all-shares parameter ${name}"`, " fi", ` posix_info="$(resolve_posix_all_shares "$${rawVar}")"`, ` ${namesVar}="$(printf '%s\\n' "$posix_info" | sed -n '1p')"`, ` ${pathsVar}="$(printf '%s\\n' "$posix_info" | sed -n '2p')"`, ` ${countVar}="$(printf '%s\\n' "$posix_info" | sed -n '3p')"`, ` rewrite_posix_all_shares "${name}" "$${namesVar}" "$${pathsVar}" "$PROJECT/docker-compose.yml"`);
827
+ }
755
828
  if (dsmUserParam) {
756
829
  const name = dsmUserParam.name;
757
830
  const rawUserVar = "D54_RAW_USER_" + name;
@@ -770,6 +843,9 @@ function renderDynamicPreinstMaterializer(contract) {
770
843
  for (const { name, pathVar } of shareVars) {
771
844
  lines.push(` append_env "${name}" "$${pathVar}" "string"`);
772
845
  }
846
+ for (const { name, namesVar, countVar } of posixVars) {
847
+ lines.push(` append_env "${name}" "$${namesVar}" "string"`, ` append_env "${name}_COUNT" "$${countVar}" "string"`);
848
+ }
773
849
  if (dsmUserParam) {
774
850
  const name = dsmUserParam.name;
775
851
  const rawUserVar = "D54_RAW_USER_" + name;
@@ -57,6 +57,7 @@ export declare function validateComposeNumericCommandArgs(compose: ComposeDocume
57
57
  export interface HostBindContext {
58
58
  sharedFolderParams: ReadonlySet<string>;
59
59
  homesParams?: ReadonlySet<string>;
60
+ posixAllSharesParams?: ReadonlySet<string>;
60
61
  installParamNames: ReadonlySet<string>;
61
62
  }
62
63
  export declare function collectVolumeSourceInterpolations(compose: ComposeDocument): Set<string>;
@@ -190,6 +190,7 @@ const HEADLESS_PACKAGE_ONLY_PARAMETER_TYPES = new Set([
190
190
  "boolean",
191
191
  "shared_folder",
192
192
  "dsm_user",
193
+ "posix_all_shares",
193
194
  ]);
194
195
  /**
195
196
  * Chooses the local route D0 can safely emit. The first dynamic slice is
@@ -252,10 +253,14 @@ function collectRouteRejectErrors(contract, parsed, composeFile) {
252
253
  const installParams = collectInstallParameters(contract);
253
254
  const sharedFolderParams = new Set(installParams.filter(({ param }) => param.type === "shared_folder").map(({ name }) => name));
254
255
  const homesParams = new Set(installParams.filter(({ param }) => param.type === "homes").map(({ name }) => name));
256
+ const posixAllSharesParams = new Set(installParams
257
+ .filter(({ param }) => param.type === "posix_all_shares")
258
+ .map(({ name }) => name));
255
259
  const installParamNames = new Set(installParams.map(({ name }) => name));
256
260
  errors.push(...validateComposeHostBinds(compose, composeFile, {
257
261
  sharedFolderParams,
258
262
  homesParams,
263
+ posixAllSharesParams,
259
264
  installParamNames,
260
265
  }));
261
266
  return dedupeValidationErrors(errors);
@@ -296,7 +301,8 @@ function collectDynamicRouteError(contract, parsed, composeFile) {
296
301
  (param.type === "string" ||
297
302
  param.type === "boolean" ||
298
303
  param.type === "shared_folder" ||
299
- param.type === "dsm_user"))
304
+ param.type === "dsm_user" ||
305
+ param.type === "posix_all_shares"))
300
306
  .map(({ name }) => name));
301
307
  const dsmUserParam = params.find(({ param }) => param.type === "dsm_user");
302
308
  const dsmUserDerivedNames = dsmUserParam
@@ -310,7 +316,8 @@ function collectDynamicRouteError(contract, parsed, composeFile) {
310
316
  (param.type !== "string" &&
311
317
  param.type !== "boolean" &&
312
318
  param.type !== "shared_folder" &&
313
- param.type !== "dsm_user"));
319
+ param.type !== "dsm_user" &&
320
+ param.type !== "posix_all_shares"));
314
321
  const brokerOidcInterpolationNames = hasBrokerManagedOidc(contract)
315
322
  ? BROKER_OIDC_INTERPOLATION_VARS
316
323
  : [];
@@ -726,7 +733,8 @@ function classifyVolumeSource(compose, mount, namedVolumes, context, composeFile
726
733
  }
727
734
  if (PLATFORM_BIND_VARS.has(varName) ||
728
735
  context.sharedFolderParams.has(varName) ||
729
- context.homesParams?.has(varName)) {
736
+ context.homesParams?.has(varName) ||
737
+ context.posixAllSharesParams?.has(varName)) {
730
738
  return null;
731
739
  }
732
740
  if (context.installParamNames.has(varName)) {
@@ -39,12 +39,12 @@ export function validatePosixAllSharesSources(compose, composeFile, declaredPara
39
39
  const path = `services.${serviceName}.volumes.${index}`;
40
40
  if (declaredParameters.has(name)) {
41
41
  if (suffix !== "") {
42
- errors.push(makeError("COMPOSE_POSIX_ALL_SHARES_SUFFIX_FORBIDDEN", composeSpan(composeFile, 1, 1, path), `POSIX all-shares 參數「${name}」只能作為完整 volume source,不能附加子路徑。`, `請將 ${source} 改成 \${${name}},並保留 Compose 的 container target;Share 名稱由 Broker 在安裝時展開。`));
42
+ errors.push(makeError("COMPOSE_POSIX_ALL_SHARES_SUFFIX_FORBIDDEN", composeSpan(composeFile, 1, 1, path), `POSIX all-shares 參數「${name}」只能作為完整 volume source,不能附加子路徑。`, `請將 ${source} 改成 \${${name}},並保留 Compose 的 container target;Share 名稱由 package-only preinst 在安裝時展開。`));
43
43
  }
44
44
  continue;
45
45
  }
46
46
  if (RESERVED_MARKER_NAMES.has(name)) {
47
- errors.push(makeError("COMPOSE_POSIX_ALL_SHARES_UNDECLARED", composeSpan(composeFile, 1, 1, path), `Compose 使用了未宣告的 POSIX all-shares marker「${name}」。`, `請在 synology-app.yaml 宣告 install.${name}.type: ${POSIX_ALL_SHARES_TYPE},並讓 Broker 以 DSM API 列出安裝當下的 Share。`));
47
+ errors.push(makeError("COMPOSE_POSIX_ALL_SHARES_UNDECLARED", composeSpan(composeFile, 1, 1, path), `Compose 使用了未宣告的 POSIX all-shares marker「${name}」。`, `請在 synology-app.yaml 宣告 install.${name}.type: ${POSIX_ALL_SHARES_TYPE},並讓 wizard/CLI 取得安裝當下的 Share 清單。`));
48
48
  }
49
49
  }
50
50
  }
@@ -30,7 +30,7 @@ function mapSchemaError(error, contractFile, parsed) {
30
30
  }
31
31
  export function validateContractSchema(parsed, contractFile) {
32
32
  const validate = getValidator();
33
- const data = schemaCompatibleContract(parsed.doc.toJSON());
33
+ const data = parsed.doc.toJSON();
34
34
  const valid = validate(data);
35
35
  if (valid) {
36
36
  return [];
@@ -48,28 +48,3 @@ export function validateContractSchema(parsed, contractFile) {
48
48
  }
49
49
  return result;
50
50
  }
51
- /**
52
- * Keep the published v1 schema's historical enum stable for older tooling,
53
- * while allowing the P4 contract extension to be validated by the same v1
54
- * object shape. The semantic validator and Broker retain the raw type.
55
- */
56
- function schemaCompatibleContract(value) {
57
- if (!value || typeof value !== "object" || Array.isArray(value)) {
58
- return value;
59
- }
60
- const contract = value;
61
- const install = contract.install;
62
- if (!install || typeof install !== "object" || Array.isArray(install)) {
63
- return value;
64
- }
65
- for (const parameter of Object.values(install)) {
66
- if (!parameter || typeof parameter !== "object" || Array.isArray(parameter)) {
67
- continue;
68
- }
69
- const record = parameter;
70
- if (record.type === "posix_all_shares") {
71
- record.type = "homes";
72
- }
73
- }
74
- return value;
75
- }
@@ -51,16 +51,6 @@ export function validateContract(parsed, contractFile) {
51
51
  errors.push(...validateInstallParameters(parsed, contractFile));
52
52
  errors.push(...validatePosixAllShares(parsed, contractFile));
53
53
  errors.push(...validateWebLaunch(parsed, contractFile));
54
- // The P4 worker cannot edit the shared host-bind analyzer. Its existing
55
- // platform-resolved `homes` slot has the same analyzer semantics (declared
56
- // interpolation is an allowed source and must be used). This normalization
57
- // is local to the validation object; Broker and SPK generation read the raw
58
- // YAML again and retain the actual posix_all_shares type.
59
- for (const parameter of Object.values(contract.install ?? {})) {
60
- if (parameter && isPosixAllSharesType(parameter.type)) {
61
- parameter.type = "homes";
62
- }
63
- }
64
54
  return errors;
65
55
  }
66
56
  function validatePosixAllShares(parsed, contractFile) {
@@ -13,6 +13,7 @@ import { homesParameterNames, resolveHomesExtraValues } from "./homes-values.js"
13
13
  import { resolveInstallParameterExtraValues } from "./param-values.js";
14
14
  import { CURATED_BUILD_OFFSET } from "@synopackageland/package-source/index-manifest";
15
15
  const CURATED_DEPLOY_REFUSED_MESSAGE = "此 NAS 已持有 Curated App Package Attestation。請改用 --dev,或使用 --replace-curated 明確降級。";
16
+ const POSIX_ALL_SHARES_LOOKUP_FAILED = "DEPLOY_POSIX_ALL_SHARES_LOOKUP_FAILED";
16
17
  function brokerCapabilitiesFromContract(project) {
17
18
  const contract = loadContract(project.contractPath).contract;
18
19
  const capabilities = ["identity"];
@@ -67,9 +68,34 @@ export async function deployProject(options) {
67
68
  return { ok: false, code: "CURATED_DEPLOY_REFUSED", message: CURATED_DEPLOY_REFUSED_MESSAGE };
68
69
  }
69
70
  const contract = loadContract(options.project.contractPath).contract;
71
+ const paramValues = { ...(options.paramValues ?? {}) };
72
+ const posixAllSharesNames = Object.entries(contract.install ?? {})
73
+ .filter(([, parameter]) => parameter?.type === "posix_all_shares")
74
+ .map(([name]) => name);
75
+ for (const name of posixAllSharesNames) {
76
+ if (Object.prototype.hasOwnProperty.call(paramValues, name)) {
77
+ continue;
78
+ }
79
+ let shareNames;
80
+ try {
81
+ shareNames = await options.host.listShareNames?.();
82
+ }
83
+ catch {
84
+ shareNames = undefined;
85
+ }
86
+ const names = (shareNames ?? []).map((value) => value.trim()).filter(Boolean);
87
+ if (names.length === 0) {
88
+ return {
89
+ ok: false,
90
+ code: POSIX_ALL_SHARES_LOOKUP_FAILED,
91
+ message: `無法用管理員 session 列出這台 NAS 的 Shared Folder,無法填入 POSIX all-shares 參數「${name}」。`,
92
+ };
93
+ }
94
+ paramValues[name] = names.join(",");
95
+ }
70
96
  const paramResolved = resolveInstallParameterExtraValues({
71
97
  contract,
72
- provided: options.paramValues,
98
+ provided: paramValues,
73
99
  });
74
100
  if (!paramResolved.ok) {
75
101
  return { ok: false, code: paramResolved.code, message: paramResolved.message };
@@ -1,4 +1,5 @@
1
1
  import { collectInstallParameters, stringViolations } from "../contract/load.js";
2
+ import { isPosixAllSharesType } from "../contract/posix-all-shares.js";
2
3
  /**
3
4
  * Validate the local, non-secret values accepted by `deploy --param`.
4
5
  *
@@ -30,7 +31,7 @@ export function resolveInstallParameterExtraValues(input) {
30
31
  extraValues[name] = raw;
31
32
  continue;
32
33
  }
33
- if (param.type !== "string") {
34
+ if (param.type !== "string" && !isPosixAllSharesType(param.type)) {
34
35
  return {
35
36
  ok: false,
36
37
  code: "DEPLOY_PARAM_TYPE_INVALID",
@@ -83,7 +83,7 @@ export const ERROR_REGISTRY = {
83
83
  CONTRACT_POSIX_ALL_SHARES_VALUE_FORBIDDEN: {
84
84
  code: "CONTRACT_POSIX_ALL_SHARES_VALUE_FORBIDDEN",
85
85
  description: "POSIX all-shares 參數不得在契約中指定 default 或 NAS 實體路徑。",
86
- suggestion: "請只宣告 type: posix_all_shares,讓 Broker 在安裝時用 DSM API 建立 Share snapshot。",
86
+ suggestion: "請只宣告 type: posix_all_shares,讓 wizard/CLI 在安裝時取得當下的 Share snapshot。",
87
87
  },
88
88
  DSM_USER_REQUIRED: {
89
89
  code: "DSM_USER_REQUIRED",
@@ -149,12 +149,12 @@ export const ERROR_REGISTRY = {
149
149
  COMPOSE_POSIX_ALL_SHARES_UNDECLARED: {
150
150
  code: "COMPOSE_POSIX_ALL_SHARES_UNDECLARED",
151
151
  description: "Compose 使用了未宣告的 POSIX all-shares marker。",
152
- suggestion: "請宣告 type: posix_all_shares,讓 Broker 透過 DSM API 建立安裝當下的 Share snapshot。",
152
+ suggestion: "請宣告 type: posix_all_shares,讓 package-only wizard/CLI 取得安裝當下的 Share snapshot。",
153
153
  },
154
154
  COMPOSE_POSIX_ALL_SHARES_SUFFIX_FORBIDDEN: {
155
155
  code: "COMPOSE_POSIX_ALL_SHARES_SUFFIX_FORBIDDEN",
156
156
  description: "POSIX all-shares marker 不得附加 Share 子路徑。",
157
- suggestion: "請只使用完整的 ${ALL_SHARES} source;每個 Share 的 container target 由 Broker 展開。",
157
+ suggestion: "請只使用完整的 ${ALL_SHARES} source;每個 Share 的 container target 由 package-only preinst 展開。",
158
158
  },
159
159
  COMPOSE_HOST_BIND_FORBIDDEN: {
160
160
  code: "COMPOSE_HOST_BIND_FORBIDDEN",
@@ -529,6 +529,11 @@ export const ERROR_REGISTRY = {
529
529
  description: "deploy --param 的值不符合契約宣告的 string 或 boolean 型別。",
530
530
  suggestion: "string 請傳非空字串;boolean 請只傳 true 或 false。",
531
531
  },
532
+ DEPLOY_POSIX_ALL_SHARES_LOOKUP_FAILED: {
533
+ code: "DEPLOY_POSIX_ALL_SHARES_LOOKUP_FAILED",
534
+ description: "無法以管理員 session 列出部署當下的 Shared Folder 清單。",
535
+ suggestion: "請確認 DSM 管理員 session 可呼叫 SYNO.Core.Share list,或用 --param NAME=... 明確提供清單。",
536
+ },
532
537
  DSM_DEPENDENCY_CONFLICT: {
533
538
  code: "DSM_DEPENDENCY_CONFLICT",
534
539
  description: "DSM 複合安裝請求因執行中的依賴套件而拒絕。",
@@ -4,7 +4,7 @@ import { tmpdir } from "node:os";
4
4
  import { join } from "node:path";
5
5
  import { IDENTITY_CGI_PATH } from "./broker-paths.js";
6
6
  import { packageSourceOrigin } from "../deploy/catalog-publish.js";
7
- import { lookupSharePathWithSynoagentcli } from "./share-lookup.js";
7
+ import { listShareNamesWithSynoagentcli, lookupSharePathWithSynoagentcli } from "./share-lookup.js";
8
8
  import { dsmFetch } from "./dsm-http.js";
9
9
  import { installPackageFromCatalog, installPackageFromLocalSpk, uninstallPackageFromDsm, fetchInstalledPackageState, packageStateFromRecord, } from "./catalog-install.js";
10
10
  import { resolveHostCredentials } from "./credentials.js";
@@ -231,6 +231,9 @@ function createDsmNasHost(credentials, profileName) {
231
231
  async resolveSharePath(logicalName) {
232
232
  return lookupSharePathWithSynoagentcli(logicalName, profileName);
233
233
  },
234
+ async listShareNames() {
235
+ return listShareNamesWithSynoagentcli(profileName);
236
+ },
234
237
  async resolveHomesPath() {
235
238
  try {
236
239
  const result = synoagentcli(["dsm", "control-panel", "user-group", "advanced", "get", "--section", "home"], profileName);
@@ -4,6 +4,7 @@ export interface FakeNasHostOptions {
4
4
  dsmOrigin?: string;
5
5
  homesPath?: string;
6
6
  installationId?: string;
7
+ shareNames?: string[];
7
8
  shares?: Record<string, string>;
8
9
  installedEnv?: Record<string, Record<string, string>>;
9
10
  diagnostics?: {
@@ -8,6 +8,11 @@ export function createFakeNasHost(options = {}) {
8
8
  const installed = new Set();
9
9
  const openUrls = new Map();
10
10
  const shares = { ...(options.shares ?? {}) };
11
+ const shareNames = options.shareNames
12
+ ? [...options.shareNames]
13
+ : Object.keys(shares).length > 0
14
+ ? Object.keys(shares)
15
+ : ["docker", "home", "photo"];
11
16
  const installedEnv = Object.fromEntries(Object.entries(options.installedEnv ?? {}).map(([packageId, env]) => [packageId, { ...env }]));
12
17
  const host = {
13
18
  dsmOrigin,
@@ -24,6 +29,9 @@ export function createFakeNasHost(options = {}) {
24
29
  async resolveSharePath(logicalName) {
25
30
  return shares[logicalName];
26
31
  },
32
+ async listShareNames() {
33
+ return [...shareNames];
34
+ },
27
35
  async resolveHomesPath() {
28
36
  return options.homesPath;
29
37
  },
@@ -1,3 +1,5 @@
1
1
  export declare function shareRealPathFromEntry(entry: Record<string, unknown>, logicalName: string): string | undefined;
2
2
  export declare function findShareRealPath(payload: unknown, logicalName: string): string | undefined;
3
+ export declare function findShareNames(payload: unknown): string[];
3
4
  export declare function lookupSharePathWithSynoagentcli(logicalName: string, profileName?: string): string | undefined;
5
+ export declare function listShareNamesWithSynoagentcli(profileName?: string): string[];
@@ -59,6 +59,16 @@ export function findShareRealPath(payload, logicalName) {
59
59
  }
60
60
  return undefined;
61
61
  }
62
+ export function findShareNames(payload) {
63
+ const names = new Set();
64
+ for (const record of collectRecords(payload)) {
65
+ const name = record.name ?? record.share_name;
66
+ if (typeof name === "string" && name.trim() !== "") {
67
+ names.add(name.trim());
68
+ }
69
+ }
70
+ return [...names];
71
+ }
62
72
  function synoagentcli(args, profileName) {
63
73
  const fullArgs = [...args];
64
74
  if (profileName) {
@@ -142,3 +152,10 @@ export function lookupSharePathWithSynoagentcli(logicalName, profileName) {
142
152
  }
143
153
  return lookupSharePathWithDsmHttp(logicalName, profileName);
144
154
  }
155
+ export function listShareNamesWithSynoagentcli(profileName) {
156
+ const list = synoagentcli(["dsm", "control-panel", "shared-folder", "list"], profileName);
157
+ if (list.status !== 0) {
158
+ return [];
159
+ }
160
+ return findShareNames(parseJson(list.stdout));
161
+ }
@@ -76,6 +76,8 @@ export interface NasHost {
76
76
  * caller refuses instead of guessing.
77
77
  */
78
78
  getArchFamily?(): Promise<"x86_64" | "aarch64" | undefined>;
79
+ /** List the current logical Shared Folder names using an administrator session. */
80
+ listShareNames?(): Promise<string[]>;
79
81
  resolveSharePath?(logicalName: string): Promise<string | undefined>;
80
82
  /** Resolve the NAS-specific user-home root when DSM User Home is enabled. */
81
83
  resolveHomesPath?(): Promise<string | undefined>;
package/dist/validate.js CHANGED
@@ -68,10 +68,14 @@ export function validateProject(project, options = {}) {
68
68
  const installParams = collectInstallParameters(parsed.contract);
69
69
  const sharedFolderParams = new Set(installParams.filter(({ param }) => param.type === "shared_folder").map(({ name }) => name));
70
70
  const homesParams = new Set(installParams.filter(({ param }) => param.type === "homes").map(({ name }) => name));
71
+ const posixAllSharesParams = new Set(installParams
72
+ .filter(({ param }) => param.type === "posix_all_shares")
73
+ .map(({ name }) => name));
71
74
  const installParamNames = new Set(installParams.map(({ name }) => name));
72
75
  errors.push(...validateComposeHostBinds(parsedCompose.compose, composeFile, {
73
76
  sharedFolderParams,
74
77
  homesParams,
78
+ posixAllSharesParams,
75
79
  installParamNames,
76
80
  }));
77
81
  if (typeof parsed.contract.adminport === "number") {
@@ -105,13 +109,14 @@ export function validateProject(project, options = {}) {
105
109
  ? { ...item, severity: (profile === "listing" ? "error" : "warning") }
106
110
  : item));
107
111
  for (const { name, param } of installParams) {
108
- const referenced = param.type === "shared_folder" || param.type === "homes"
112
+ const referenced = param.type === "shared_folder" || param.type === "homes" || param.type === "posix_all_shares"
109
113
  ? volumeSourceVars.has(name)
110
114
  : interpolations.has(name);
111
115
  if ((param.type === "string" ||
112
116
  param.type === "boolean" ||
113
117
  param.type === "shared_folder" ||
114
- param.type === "homes") &&
118
+ param.type === "homes" ||
119
+ param.type === "posix_all_shares") &&
115
120
  !referenced) {
116
121
  errors.push({
117
122
  code: "INSTALL_PARAMETER_UNREFERENCED",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@synopackageland/cli",
3
- "version": "0.3.7",
3
+ "version": "0.3.8",
4
4
  "description": "Syno Package Land Developer Kit CLI",
5
5
  "type": "module",
6
6
  "bin": {
@@ -59,7 +59,7 @@
59
59
  "properties": {
60
60
  "type": {
61
61
  "type": "string",
62
- "enum": ["string", "boolean", "secret", "shared_folder", "dsm_user", "homes"]
62
+ "enum": ["string", "boolean", "secret", "shared_folder", "dsm_user", "homes", "posix_all_shares"]
63
63
  },
64
64
  "default": {},
65
65
  "generate": {
@@ -38,6 +38,10 @@ ports:
38
38
 
39
39
  包現成 Compose 時,先用 `inspect` 看原本的 volume mapping。容器路徑跟 Compose/映像的 upstream target 走,不要為了套平台範例改成 `/data`;只把 host source 換成 `${PARAM}`。原有的 upstream named volume 要保留在頂層 `volumes:`。需要既有 Share 時宣告 `install.<PARAM>.type: shared_folder`,管理員在安裝時選這台 NAS 的 Share。
40
40
 
41
+ ### `posix_all_shares`
42
+
43
+ 當 App 必須在安裝當下掛載這台 NAS 目前列出的每一個共用資料夾時,宣告 `install.<NAME>.type: posix_all_shares`,並把 `${NAME}` 作為完整 Compose volume source,例如 `${NAME}:/shares:ro`。Package Center wizard 會以 `SYNO.Core.Share.list` 的 multi-select 讓管理員再次確認;這是最大警告:容器可讀(或依 Compose 模式讀寫)所有列出的 Share,安裝後新建的共用資料夾不會自動加入,由管理員決定是否安裝。不要在契約或 Compose 寫 `/volume*`、domain、實體 path;`CLI deploy` 未給 `--param NAME=...` 時會用管理員 session 重新列出當下的 Share,preinst 只解析清單,不掃描檔案系統。
44
+
41
45
  ## 路線判準:零設定(Static Zero-Config)vs Dynamic vs Broker
42
46
 
43
47
  Syno Package Land 在打包 Compose App 時,依契約與 Compose 內容自動分類為不同路線: