@synopackageland/cli 0.30.0 → 0.32.0

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/README.md CHANGED
@@ -29,6 +29,14 @@ synopkgland status --host my-nas --json
29
29
  `deploy --dry-run` prints the deployment plan without connecting to a NAS. You can also install
30
30
  the SPK with Package Center's Manual Install.
31
31
 
32
+ When packaging someone else's open-source App, name its original project/team
33
+ (or owner display name, then login) as maintainer and yourself as distributor.
34
+ Use `init --upstream-fork --upstream <repo-url> --publisher <name> --publisher-url <url>`;
35
+ `--upstream-maintainer` records a verified project/team name. Publisher defaults
36
+ come from `SYNOPKGLAND_PUBLISHER` / `SYNOPKGLAND_PUBLISHER_URL`. Offline lookups
37
+ leave TODOs; `check --json` reports attribution warnings without failing.
38
+ See the [upstream scaffold and release checklist](skills/reference/upstream-fork.md).
39
+
32
40
  ## Templates
33
41
 
34
42
  | Template | Use it for | Manifest |
@@ -62,12 +70,12 @@ alongside npm lockfiles. Do not disable checksum verification for public SDK
62
70
  packages. Private mirrors can use explicitly configured Go authentication and
63
71
  checksum settings; a failed fetch reports `SDK_DOWNLOAD_FAILED`.
64
72
 
65
- CLI-only patch releases can differ from the SDK patch. Compose import and SDK defaults
73
+ CLI-only releases can differ from the SDK patch. Compose import and SDK defaults
66
74
  read the SDK pin from the installed templates; explicit App SDK declarations take precedence.
67
75
 
68
76
  `init` and `add` read their templates from the locally installed
69
- `@synopackageland/templates`. CLI 0.30.0 depends on `~0.30.0`: npm installation or
70
- update can select the latest templates patch in the 0.29 minor line. Templates
77
+ `@synopackageland/templates`. CLI 0.32.0 depends on `~0.31.0`: npm installation or
78
+ update can select the latest templates patch in the 0.31 minor line. Templates
71
79
  patches need only a templates release; update the npm installation to receive them.
72
80
  The CLI never fetches templates at runtime, so offline use works after installation;
73
81
  commit a lockfile and use `npm ci` to reproduce a chosen dependency tree.
@@ -339,7 +347,10 @@ DSM readiness; inspect lists explicit acceptance blockers.
339
347
  ## Control installed Apps
340
348
 
341
349
  Use `synopkgland ctl host add nas --url https://nas.example:5001 --username alice`
342
- to register and authenticate a NAS in OS secure storage. `ctl host list --json`
350
+ to register and authenticate a NAS in OS secure storage. A bare host name uses
351
+ `https://<host>:5001`; provide a full URL for a different scheme or port.
352
+ The CLI loads only `DSM_*` and `SYNOPKGLAND_*` variables from `.env`; set other
353
+ process variables in the shell. `ctl host list --json`
343
354
  checks sessions; `ctl list --host nas --json` discovers all approved task-level
344
355
  commands and their schemas. Run the listed alias/command with typed flags,
345
356
  `--help` and `--dry-run`; writes require `--yes`. There is no mutable current
package/dist/bin.js CHANGED
@@ -1,10 +1,11 @@
1
1
  #!/usr/bin/env node
2
2
  import { loadDotEnv } from "./load-env.js";
3
3
  import { runCli } from "./cli.js";
4
+ import { terminalSafe } from "./terminal-text.js";
4
5
  loadDotEnv();
5
6
  // Package deployment is explicit; this CLI does not publish releases.
6
7
  // Successful completion leaves Node's default 0 or the command's exitCode intact.
7
8
  runCli(process.argv).catch((error) => {
8
- console.error(error instanceof Error ? error.message : String(error));
9
+ console.error(terminalSafe(error instanceof Error ? error.message : String(error)));
9
10
  process.exit(1);
10
11
  });
package/dist/cli.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { upstreamRepository, upstreamMaintainer } from './upstream-attribution.js';
1
2
  import { Command, CommanderError } from "commander";
2
3
  import { existsSync, readFileSync, renameSync, statSync } from "node:fs";
3
4
  import { createHash } from "node:crypto";
@@ -36,6 +37,7 @@ import { checkJobs, commitOutputs, forEachArch, } from "./standalone/arch-jobs.j
36
37
  import { checkReproducible } from "./reproducible.js";
37
38
  import { spkApprovalDiff, formatApprovalDiff } from "./runtime/approval-diff.js";
38
39
  import { inspectAnySpk } from "./spk/inspect.js";
40
+ import { terminalSafe } from "./terminal-text.js";
39
41
  function failure(code, message, hint = "Fix the configuration and retry; use --help to see command usage.") {
40
42
  return Object.assign(new Error(codedMessage(code, message)), { code, hint });
41
43
  }
@@ -295,7 +297,7 @@ export async function runCli(argv) {
295
297
  const json = argv.includes("--json");
296
298
  const stdout = console.log.bind(console);
297
299
  const progress = console.log;
298
- const emit = (command, data, text) => json ? stdout(JSON.stringify({ ok: true, command, data })) : stdout(text);
300
+ const emit = (command, data, text) => json ? stdout(JSON.stringify({ ok: true, command, data })) : stdout(terminalSafe(text));
299
301
  const program = new Command()
300
302
  .name("synopkgland")
301
303
  .description("Syno Package Land Developer Kit CLI")
@@ -323,6 +325,8 @@ export async function runCli(argv) {
323
325
  throw failure(validation.errors[0].code, formatErrors(validation.errors), validation.errors[0].suggestion);
324
326
  const standalone = standaloneRoot(rootOf(command));
325
327
  if (standalone) {
328
+ if (validation.warnings?.length)
329
+ console.error(formatErrors(validation.warnings));
326
330
  const loaded = isNativeLayout(standalone)
327
331
  ? loadNativeLayout(standalone)
328
332
  : loadStandaloneManifest(standalone);
@@ -347,6 +351,7 @@ export async function runCli(argv) {
347
351
  : undefined;
348
352
  emit("check", {
349
353
  package: loaded.manifest.package,
354
+ warnings: validation.warnings ?? [],
350
355
  ...("installSettings" in loaded.manifest && loaded.manifest.installSettings ? { installSettings: loaded.manifest.installSettings } : {}),
351
356
  ...("identityGateway" in loaded.manifest && gatewayAdmissionReport(loaded.manifest.identityGateway, loaded.manifest.entry) ? { identityGateway: gatewayAdmissionReport(loaded.manifest.identityGateway, loaded.manifest.entry) } : {}),
352
357
  capabilities: "capabilities" in loaded.manifest
@@ -441,6 +446,10 @@ export async function runCli(argv) {
441
446
  .option("--package <id>", "INFO.package (defaults to the directory name)")
442
447
  .option("--name <display>", "Display name")
443
448
  .option("--upstream-image <repository>", "compose fork: official image repository; upgrade pins its matching tag and index digest")
449
+ .option("--upstream <url>", "Original upstream repository URL (defaults to the upstream Git remote)")
450
+ .option("--upstream-maintainer <name>", "Verified original project/team name; otherwise resolve the GitHub owner")
451
+ .option("--publisher <name>", "Your distributor name (or SYNOPKGLAND_PUBLISHER)")
452
+ .option("--publisher-url <url>", "Your distributor URL (or SYNOPKGLAND_PUBLISHER_URL)")
444
453
  .option("--upstream-fork", "Create only synology/ inside an upstream source fork (compose or native-go)")
445
454
  .option("--health-path <path>", "compose: actual readiness endpoint; redirects are not followed")
446
455
  .option("--health-status <status>", "compose: expected 2xx readiness status (no default)", Number)
@@ -454,7 +463,7 @@ export async function runCli(argv) {
454
463
  .option("--min-dsm <version>", `Minimum DSM version (native templates and binary default to ${DEFAULT_MIN_DSM}, compose to ${CONTAINER_MIN_DSM})`)
455
464
  .argument("[dir]", "Target directory", ".")
456
465
  .addHelpText("after", "\nNative projects: native-go, native-python, native-node, native-rust and binary use dsm-package.yaml schema 2.\nCompose projects: compose uses dsm-package.yaml schema 2; --from imports compose.yaml.\nQuick start: synopkgland init -t native-go --with files,jobs,login my-app\n cd my-app && synopkgland build\n")
457
- .action((dir, options, command) => {
466
+ .action(async (dir, options, command) => {
458
467
  if (command.parent?.opts().project)
459
468
  throw failure("CLI_OPTION_INVALID", "--project does not apply to init.");
460
469
  if (!options.template)
@@ -462,7 +471,16 @@ export async function runCli(argv) {
462
471
  if (!isTemplateName(options.template))
463
472
  throw failure("INIT_TEMPLATE_UNKNOWN", `Unknown template: ${options.template}`);
464
473
  const targetDir = resolve(dir);
474
+ const upstream = options.upstreamFork ? upstreamRepository(targetDir, options.upstream) : undefined;
475
+ if (options.upstream && !upstream)
476
+ throw failure("INIT_UPSTREAM_INVALID", "Supply an http(s) repository URL without credentials");
477
+ if (!options.upstreamFork && (options.upstream || options.upstreamMaintainer || options.publisher || options.publisherUrl))
478
+ throw failure("INIT_OPTIONS_UNSUPPORTED", "Attribution options require --upstream-fork");
465
479
  initFromTemplate(options.template, targetDir, {
480
+ upstream,
481
+ upstreamMaintainer: options.upstreamMaintainer ?? (options.upstreamFork ? await upstreamMaintainer(upstream) : undefined),
482
+ publisher: options.publisher ?? process.env.SYNOPKGLAND_PUBLISHER,
483
+ publisherUrl: options.publisherUrl ?? process.env.SYNOPKGLAND_PUBLISHER_URL,
466
484
  upstreamFork: options.upstreamFork,
467
485
  upstreamImage: options.upstreamImage,
468
486
  healthPath: options.healthPath,
package/dist/ctl/cli.js CHANGED
@@ -52,7 +52,7 @@ function q(value) { return "'" + value.replaceAll("'", "'\\''") + "'"; }
52
52
  function example(host, app, op) {
53
53
  const base = `synopkgland ctl ${app.payload.aliases[0] ?? app.payload.package.id} ${op.cli.join(' ')} --host ${host}`;
54
54
  return (op.examples?.length ? op.examples : [{}]).map(params => {
55
- const simple = op.paramsSchema && Object.entries(params).every(([key, value]) => ['string', 'number', 'boolean'].includes(typeof value) && !RESERVED_FLAGS.has(flagName(key)) && op.paramsSchema?.properties?.[key]);
55
+ const simple = op.paramsSchema && Object.entries(params).every(([key, value]) => ['string', 'number', 'boolean'].includes(typeof value) && /^[A-Za-z][A-Za-z0-9_]*$/.test(key) && !RESERVED_FLAGS.has(flagName(key)) && op.paramsSchema?.properties?.[key]);
56
56
  if (simple)
57
57
  return base + Object.entries(params).map(([key, value]) => ' --' + flagName(key) + ' ' + q(String(value))).join('') + (op.effect === 'read' ? '' : ' --yes');
58
58
  return `printf '%s' ${q(JSON.stringify(params))} | ${base} --input -` + (op.effect === 'read' ? '' : ' --yes');
package/dist/ctl/json.js CHANGED
@@ -1,9 +1,43 @@
1
1
  import { openSync, closeSync, readSync } from 'node:fs';
2
- import { parseDocument } from 'yaml';
2
+ /** Linear duplicate-key scan over text that JSON.parse already accepted (yaml's uniqueKeys check is quadratic). */
3
+ function hasDuplicateKeys(text) {
4
+ const stack = [];
5
+ let expectKey = false;
6
+ for (let i = 0; i < text.length; i++) {
7
+ const c = text[i];
8
+ if (c === '"') {
9
+ let j = i + 1;
10
+ while (text[j] !== '"')
11
+ j += text[j] === '\\' ? 2 : 1;
12
+ const top = stack[stack.length - 1];
13
+ if (top && expectKey) {
14
+ const key = JSON.parse(text.slice(i, j + 1));
15
+ if (top.has(key))
16
+ return true;
17
+ top.add(key);
18
+ expectKey = false;
19
+ }
20
+ i = j;
21
+ }
22
+ else if (c === '{') {
23
+ stack.push(new Set());
24
+ expectKey = true;
25
+ }
26
+ else if (c === '[') {
27
+ stack.push(null);
28
+ }
29
+ else if (c === '}' || c === ']') {
30
+ stack.pop();
31
+ }
32
+ else if (c === ',' && stack[stack.length - 1])
33
+ expectKey = true;
34
+ }
35
+ return false;
36
+ }
3
37
  export function strictJson(raw) {
4
38
  const text = typeof raw === 'string' ? raw : new TextDecoder('utf-8', { fatal: true }).decode(raw);
5
39
  const value = JSON.parse(text);
6
- if (parseDocument(text, { uniqueKeys: true }).errors.length)
40
+ if (hasDuplicateKeys(text))
7
41
  throw Error('Duplicate JSON properties');
8
42
  const check = (v, depth) => { if (depth > 32)
9
43
  throw Error('JSON too deep'); if (typeof v === 'number' && !Number.isFinite(v))
@@ -37,7 +37,7 @@ export class CtlClient {
37
37
  const entries = response.data?.packages;
38
38
  if (!Array.isArray(entries) || response.data.total !== entries.length || entries.length > 4096)
39
39
  throw new CtlError('CTL_INVENTORY_INVALID', 'Incomplete installed-package inventory.', 'Check the DSM version and permissions.', 5);
40
- return entries.map((p) => { if (typeof p.id !== 'string' || !/^[A-Za-z][A-Za-z0-9_.+-]{0,127}$/.test(p.id) || typeof p.version !== 'string')
40
+ return entries.map((p) => { if (typeof p.id !== 'string' || !/^[A-Za-z][A-Za-z0-9_.+-]{0,127}$/.test(p.id) || typeof p.version !== 'string' || !/^[A-Za-z0-9][A-Za-z0-9._+~-]{0,63}$/.test(p.version))
41
41
  throw new CtlError('CTL_INVENTORY_INVALID', 'Invalid package identity.', 'Check the DSM response.', 5); return { id: p.id, version: p.version, state: p.additional?.status }; });
42
42
  }
43
43
  async discover(options = {}) {
@@ -1,6 +1,9 @@
1
1
  import { COMPOSE_ERRORS } from "./standalone/compose-errors.js";
2
2
  export const ERROR_REGISTRY = {
3
3
  ...COMPOSE_ERRORS,
4
+ INIT_UPSTREAM_INVALID: { code: "INIT_UPSTREAM_INVALID", description: "The upstream repository URL is invalid.", suggestion: "Supply --upstream with an http(s) repository URL without credentials." },
5
+ INIT_PUBLISHER_INVALID: { code: "INIT_PUBLISHER_INVALID", description: "The attribution name or publisher URL is invalid.", suggestion: "Use plain single-line project/publisher names and an http(s) publisher URL without credentials." },
6
+ UPSTREAM_ATTRIBUTION: { code: "UPSTREAM_ATTRIBUTION", description: "Upstream App attribution is incomplete or names the distributor as maintainer.", suggestion: "Name the original project/team or owner as maintainer; set metadata.maintainer_url to the upstream repository and metadata.distributor/distributor_url to your publisher; resolve TODOs before release." },
4
7
  INSTALL_SETTINGS_INVALID: { "code": "INSTALL_SETTINGS_INVALID", "description": "The installation setting declaration is invalid.", "suggestion": "Declare 1–32 lowercase identifier keys in install.settings, each with type https-origin, a plain label and an explicit boolean required; omit defaults and values." },
5
8
  SDK_INSTALL_SETTINGS_UNSUPPORTED: { "code": "SDK_INSTALL_SETTINGS_UNSUPPORTED", "description": "The selected SDK does not provide native installation settings.", "suggestion": "Pin SDK 0.24.0 or a matching supported release with installsettings/cmd/control, then rebuild with the matching CLI." },
6
9
  SDK_INSTALL_SETTINGS_BUILD_FAILED: { "code": "SDK_INSTALL_SETTINGS_BUILD_FAILED", "description": "The SDK installation settings controller could not be built.", "suggestion": "Install the pinned Go toolchain, inspect the compiler diagnostics and rebuild with a complete matching SDK release." },
@@ -56,7 +56,7 @@ function fromEnv(urlOverride) {
56
56
  if (urlOverride) {
57
57
  dsmOrigin = urlOverride.startsWith("http://") || urlOverride.startsWith("https://")
58
58
  ? urlOverride
59
- : `http://${urlOverride}:5000`;
59
+ : `https://${urlOverride}:5001`;
60
60
  }
61
61
  const password = process.env.DSM_PASSWORD;
62
62
  const username = process.env.DSM_USERNAME ?? "admin";
@@ -52,30 +52,44 @@ export async function dsmFetch(url, init = {}) {
52
52
  return curlFetch(url, init);
53
53
  }
54
54
  }
55
+ function curlConfigString(value) {
56
+ const escapes = { "\\": "\\\\", '"': '\\"', "\t": "\\t", "\n": "\\n", "\r": "\\r", "\v": "\\v" };
57
+ return `"${value.replace(/[\\"\t\n\r\v]/g, (c) => escapes[c])}"`;
58
+ }
55
59
  async function curlFetch(url, init) {
56
60
  const staging = mkdtempSync(join(tmpdir(), "synopkgland-dsm-http-"));
57
61
  const headerFile = join(staging, "headers");
58
62
  const bodyFile = join(staging, "body");
59
63
  try {
60
- const args = ["-sS", "-D", headerFile, "-o", bodyFile, "-w", "%{http_code}", "-X", init.method ?? "GET"];
64
+ const configFile = join(staging, "config");
65
+ const args = ["-sS", "-D", headerFile, "-o", bodyFile, "-w", "%{http_code}", "-X", init.method ?? "GET", "-K", configFile];
66
+ // Headers and form text go through a private config file, not argv, and
67
+ // form text is sent literally (curl -F would read "@file" and "<file").
68
+ const config = [];
61
69
  const headers = headerRecord(init.headers);
62
70
  for (const [name, value] of Object.entries(headers)) {
63
71
  if (name.toLowerCase() === "content-type" && init.body instanceof FormData) {
64
72
  continue;
65
73
  }
66
- args.push("-H", `${name}: ${value}`);
74
+ if (/[\r\n\0]/.test(name + value)) {
75
+ throw new Error(`Invalid header value: ${name}`);
76
+ }
77
+ config.push(`header = ${curlConfigString(`${name}: ${value}`)}`);
67
78
  }
68
79
  let stdin;
69
80
  if (init.body instanceof FormData) {
70
81
  for (const [name, value] of init.body.entries()) {
71
82
  if (typeof value === "string") {
72
- args.push("-F", `${name}=${value}`);
83
+ if (/[=\0]/.test(name) || value.includes("\0")) {
84
+ throw new Error(`Invalid form field: ${name}`);
85
+ }
86
+ config.push(`form-string = ${curlConfigString(`${name}=${value}`)}`);
73
87
  continue;
74
88
  }
75
89
  const filename = "name" in value && typeof value.name === "string" ? value.name : "upload.bin";
76
90
  const partPath = join(staging, filename);
77
91
  writeFileSync(partPath, Buffer.from(await value.arrayBuffer()));
78
- args.push("-F", `${name}=@${partPath};filename=${filename}`);
92
+ config.push(`form = ${curlConfigString(`${name}=@${partPath};filename=${filename}`)}`);
79
93
  }
80
94
  }
81
95
  else if (typeof init.body === "string") {
@@ -86,6 +100,7 @@ async function curlFetch(url, init) {
86
100
  stdin = Buffer.from(await new Response(init.body).arrayBuffer());
87
101
  args.push("--data-binary", "@-");
88
102
  }
103
+ writeFileSync(configFile, config.map((line) => line + "\n").join(""), { mode: 0o600 });
89
104
  args.push(url);
90
105
  const result = spawnSync("curl", args, { encoding: "utf8", input: stdin });
91
106
  if (result.status !== 0) {
@@ -1,6 +1,10 @@
1
1
  export interface ForkRecipeOptions {
2
2
  upstreamImage?: string;
3
3
  from?: string;
4
+ upstream?: string;
5
+ upstreamMaintainer?: string;
6
+ publisher?: string;
7
+ publisherUrl?: string;
4
8
  }
5
9
  export declare function validateForkRecipe(template: 'compose' | 'native-go', options: ForkRecipeOptions): void;
6
10
  /** CLI-owned packaging recipe; upstream files and history stay untouched. */
@@ -3,8 +3,17 @@ import { join } from 'node:path';
3
3
  import { fileURLToPath } from 'node:url';
4
4
  import { parseDocument } from 'yaml';
5
5
  import { API_PERMISSIONS_FILE } from './standalone/api-permissions.js';
6
+ import { safeMetadataUrl } from './package-metadata.js';
6
7
  import { parseImageReference } from './images/pin.js';
7
8
  export function validateForkRecipe(template, options) {
9
+ if (options.upstream && !safeMetadataUrl(options.upstream))
10
+ throw new Error('[INIT_UPSTREAM_INVALID] Supply an http(s) upstream repository URL without credentials.');
11
+ if (options.publisherUrl && !safeMetadataUrl(options.publisherUrl))
12
+ throw new Error('[INIT_PUBLISHER_INVALID] Supply an http(s) publisher URL without credentials.');
13
+ for (const value of [options.publisher, options.upstreamMaintainer]) {
14
+ if (value !== undefined && (!value.trim() || /[\x00-\x1f\x7f"\\]/.test(value) || value.length > 200))
15
+ throw new Error('[INIT_PUBLISHER_INVALID] Attribution names must be plain, non-empty single-line text.');
16
+ }
8
17
  if (template === 'native-go' && options.upstreamImage)
9
18
  throw new Error('[INIT_OPTIONS_UNSUPPORTED] Native forks stage official release binaries or use a source build hook; image options apply only to compose.');
10
19
  if (template !== 'compose')
@@ -28,6 +37,14 @@ export function writeForkScaffold(packaging, template, options) {
28
37
  ? { kind: 'official-image', repository: options.upstreamImage, service, imageTag: 'tag' }
29
38
  : { kind: 'native-source' };
30
39
  writeFileSync(join(packaging, 'upstream-recipe.json'), JSON.stringify(recipe, null, 2) + '\n');
40
+ doc.set('maintainer', options.upstreamMaintainer ?? 'TODO: original upstream project or owner');
41
+ doc.setIn(['metadata', 'distributor'], options.publisher ?? 'TODO: your publisher name');
42
+ if (options.upstream) {
43
+ doc.setIn(['metadata', 'upstream'], options.upstream);
44
+ doc.setIn(['metadata', 'maintainer_url'], options.upstream);
45
+ }
46
+ if (options.publisherUrl)
47
+ doc.setIn(['metadata', 'distributor_url'], options.publisherUrl);
31
48
  doc.set('entry', 'admin-only');
32
49
  doc.delete('ctl');
33
50
  if (official) {
@@ -53,6 +70,8 @@ export function writeForkScaffold(packaging, template, options) {
53
70
  const guidance = `
54
71
  ## Integrating an existing open-source App
55
72
 
73
+ If you package someone else's open-source project, set maintainer to the original project/owner (prefer the project's own team/org name, else the owner's display name, else login), metadata.maintainer_url to the upstream repo, and yourself as metadata.distributor/metadata.distributor_url. Resolve all attribution TODOs before release; run check and review its warnings. Only your own Apps use your publisher as maintainer.
74
+
56
75
  Keep the upstream fork/history and remote upstream. All wrapper-owned files belong in synology/; never edit upstream files or treat the App as an SDK component. Read synopkgland skill show upstream-fork.
57
76
 
58
77
  Leave ctl absent from the manifest by default. Ask whether the user wants AI control; add ctl only when explicitly requested. If requested, read synopkgland skill show ctl and produce ctl-plan covering existing UI actions and public API endpoints, including common reads and writes. Status-only commands do not meet completion. Wrap existing native APIs without changing upstream code or inventing features. Long operations must return through existing asynchronous APIs without waiting for completion; report synchronous-only long APIs as blockers for discussion.
package/dist/init.d.ts CHANGED
@@ -18,6 +18,10 @@ export interface InitOptions {
18
18
  from?: string;
19
19
  upstreamFork?: boolean;
20
20
  upstreamImage?: string;
21
+ upstream?: string;
22
+ upstreamMaintainer?: string;
23
+ publisher?: string;
24
+ publisherUrl?: string;
21
25
  healthPath?: string;
22
26
  healthStatus?: number;
23
27
  /** native-go: `install.sharedFolder` (select from the NAS, or create one). key defaults to "shared", permission to "rw". */
package/dist/load-env.js CHANGED
@@ -1,5 +1,8 @@
1
1
  import { existsSync, readFileSync } from "node:fs";
2
2
  import { dirname, join, resolve } from "node:path";
3
+ // A project .env may only supply the CLI's own settings, never variables that
4
+ // change how child tools (go, docker, curl, tar) run.
5
+ const DOTENV_KEY = /^(?:DSM|SYNOPKGLAND)_[A-Z0-9_]+$/;
3
6
  export function loadDotEnv(fromDir = process.cwd()) {
4
7
  let current = resolve(fromDir);
5
8
  while (true) {
@@ -16,7 +19,7 @@ export function loadDotEnv(fromDir = process.cwd()) {
16
19
  }
17
20
  const key = trimmed.slice(0, eq).trim();
18
21
  const value = trimmed.slice(eq + 1).trim();
19
- if (process.env[key] === undefined) {
22
+ if (DOTENV_KEY.test(key) && process.env[key] === undefined) {
20
23
  process.env[key] = value;
21
24
  }
22
25
  }
@@ -7,6 +7,7 @@ export interface PackageMetadata {
7
7
  support_email?: string;
8
8
  homepage?: string;
9
9
  source_url?: string;
10
+ upstream?: string;
10
11
  issues_url?: string;
11
12
  license?: string;
12
13
  maintainer_url?: string;
@@ -27,6 +27,7 @@ export function validateMetadata(raw) {
27
27
  "support_email",
28
28
  "homepage",
29
29
  "source_url",
30
+ "upstream",
30
31
  "issues_url",
31
32
  "license",
32
33
  "maintainer_url",
@@ -66,6 +67,7 @@ export function validateMetadata(raw) {
66
67
  if ([
67
68
  "homepage",
68
69
  "source_url",
70
+ "upstream",
69
71
  "issues_url",
70
72
  "maintainer_url",
71
73
  "distributor_url",
@@ -1037,6 +1037,11 @@ function readInfo(path, findings, kind, compose = false, daemonNode) {
1037
1037
  findings.push(`INFO is missing ${key}`);
1038
1038
  }
1039
1039
  }
1040
+ // Every SDK build writes this package grammar; inspect renders paths and
1041
+ // configs from it, so anything else is not an SDK-built package.
1042
+ if (info.package && !/^[A-Za-z][A-Za-z0-9]{1,63}$/.test(info.package)) {
1043
+ findings.push("INFO package is not a valid SDK package id");
1044
+ }
1040
1045
  if (kind !== "container" &&
1041
1046
  (info.thirdparty !== "yes" || info.dsmuidir !== "ui")) {
1042
1047
  findings.push('INFO must have thirdparty="yes" and dsmuidir="ui" for DSM to serve /webman/3rdparty/<package>/');
@@ -6,9 +6,9 @@ import { resolvedSdkLaunch } from "../contract/app-launch.js";
6
6
  import { gatewayAdmissionPolicy } from "./identity-gateway.js";
7
7
  import { metadataInfoFields } from "../package-metadata.js";
8
8
  import { createHash } from "node:crypto";
9
- import { chmodSync, copyFileSync, existsSync, lstatSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync, } from "node:fs";
9
+ import { chmodSync, copyFileSync, existsSync, lstatSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync, realpathSync, } from "node:fs";
10
10
  import { tmpdir } from "node:os";
11
- import { dirname, join } from "node:path";
11
+ import { dirname, join, sep } from "node:path";
12
12
  import { infoArchValue } from "../build-spk/dsm-arch.js";
13
13
  import { packageIcons, writeDsmWindowIcons, writePackageCenterIcons, } from "../build-spk/dsm-icons.js";
14
14
  import { renderNativePrivilege } from "../build-spk/native-lifecycle.js";
@@ -78,7 +78,7 @@ export async function assembleStandaloneSpk(options) {
78
78
  const uiRoot = join(staging, "package", "ui");
79
79
  const appDir = join(uiRoot, "app");
80
80
  if (hasSdkWindow(manifest))
81
- copyUiTree(manifest.ui.dir, appDir);
81
+ copyUiTree(manifest.ui.dir, appDir, manifest.root);
82
82
  else
83
83
  mkdirSync(appDir, { recursive: true });
84
84
  const assetVersion = digestTree(appDir).slice(0, 12);
@@ -386,12 +386,17 @@ export function renderInfo(manifest, arch, checksum, executorDigest) {
386
386
  fields.push(['startstop_restart_services', 'nginx.service']);
387
387
  return portalInfoFields(fields, Boolean(manifest.portal) || nginx).map(([key, value]) => `${key}=${quote(value)}\n`).join("");
388
388
  }
389
- function copyUiTree(source, target) {
389
+ function copyUiTree(source, target, root) {
390
390
  for (const entry of ["app.js", "app.css"]) {
391
391
  if (!existsSync(join(source, entry))) {
392
392
  throw new StandaloneBuildError("STANDALONE_UI_ENTRY_MISSING", `The UI directory is missing ${entry}: ${source}`);
393
393
  }
394
394
  }
395
+ const real = realpathSync(source);
396
+ const realRoot = realpathSync(root);
397
+ if (real !== realRoot && !real.startsWith(realRoot + sep)) {
398
+ throw new StandaloneBuildError("STANDALONE_UI_SYMLINK", `The UI directory must resolve inside the project: ${source}`);
399
+ }
395
400
  let total = 0;
396
401
  const walk = (from, to) => {
397
402
  mkdirSync(to, { recursive: true });
@@ -0,0 +1 @@
1
+ export declare function terminalSafe(text: string): string;
@@ -0,0 +1,5 @@
1
+ // Package-supplied strings (INFO fields, file names, DSM data) reach human
2
+ // output; show control characters as escapes so they cannot drive the terminal.
3
+ export function terminalSafe(text) {
4
+ return text.replace(/[\u0000-\u0008\u000b-\u001f\u007f-\u009f]/g, (c) => `\\x${c.charCodeAt(0).toString(16).padStart(2, "0")}`);
5
+ }
@@ -0,0 +1,7 @@
1
+ import type { ValidationError } from './types.js';
2
+ /** A repository URL is provenance, never a credential-bearing transport URL. */
3
+ export declare function upstreamRepository(root: string, explicit?: string): string | undefined;
4
+ /** Best effort, bounded and public: offline/rate-limited lookups retain an explicit TODO. */
5
+ export declare function upstreamMaintainer(url?: string, fetcher?: typeof fetch): Promise<string | undefined>;
6
+ /** Advice only: attribution never changes manifest validity or the command exit status. */
7
+ export declare function upstreamAttributionWarnings(root: string): ValidationError[];
@@ -0,0 +1,56 @@
1
+ import { existsSync, readFileSync } from 'node:fs';
2
+ import { basename, join } from 'node:path';
3
+ import { spawnSync } from 'node:child_process';
4
+ import { parse } from 'yaml';
5
+ import { safeMetadataUrl } from './package-metadata.js';
6
+ /** A repository URL is provenance, never a credential-bearing transport URL. */
7
+ export function upstreamRepository(root, explicit) {
8
+ const raw = explicit ?? spawnSync('git', ['remote', 'get-url', 'upstream'], {
9
+ cwd: root, encoding: 'utf8', timeout: 1000,
10
+ }).stdout?.trim();
11
+ if (!raw)
12
+ return undefined;
13
+ const url = raw.replace(/^git@github\.com:/, 'https://github.com/').replace(/\.git$/, '');
14
+ return safeMetadataUrl(url) ? url : undefined;
15
+ }
16
+ /** Best effort, bounded and public: offline/rate-limited lookups retain an explicit TODO. */
17
+ export async function upstreamMaintainer(url, fetcher = fetch) {
18
+ if (!url)
19
+ return undefined;
20
+ const match = /^https:\/\/github\.com\/([\w-]+)\/[^/?#]+\/?$/.exec(url);
21
+ if (!match)
22
+ return undefined;
23
+ try {
24
+ const response = await fetcher(`https://api.github.com/users/${match[1]}`, {
25
+ headers: { Accept: 'application/vnd.github+json' }, signal: AbortSignal.timeout(3000),
26
+ });
27
+ if (!response.ok)
28
+ return undefined;
29
+ const owner = await response.json();
30
+ const name = typeof owner.name === 'string' && owner.name.trim() ? owner.name.trim() : owner.login;
31
+ return typeof name === 'string' && name.length <= 200 && !/[\x00-\x1f\x7f"\\]/.test(name) ? name : undefined;
32
+ }
33
+ catch {
34
+ return undefined;
35
+ }
36
+ }
37
+ /** Advice only: attribution never changes manifest validity or the command exit status. */
38
+ export function upstreamAttributionWarnings(root) {
39
+ const raw = parse(readFileSync(join(root, 'dsm-package.yaml'), 'utf8'));
40
+ const m = raw?.metadata ?? {};
41
+ const fork = existsSync(join(root, 'upstream-recipe.json')) ||
42
+ (basename(root) === 'synology' && existsSync(join(root, 'upgrade.sh')));
43
+ if (!m.upstream && !fork)
44
+ return [];
45
+ const todo = (v) => typeof v !== 'string' || !v.trim() || /^TODO\b/i.test(v.trim());
46
+ if (!todo(raw.maintainer) && !todo(m.distributor) &&
47
+ raw.maintainer.trim().toLowerCase() !== m.distributor.trim().toLowerCase() &&
48
+ !todo(m.maintainer_url) && !todo(m.distributor_url))
49
+ return [];
50
+ return [{
51
+ code: 'UPSTREAM_ATTRIBUTION', severity: 'warning',
52
+ span: { file: 'dsm-package.yaml', line: 1, column: 1 },
53
+ message: 'Upstream App attribution is incomplete or names the distributor as maintainer',
54
+ suggestion: "Set maintainer to the original project/team, else the owner's display name or login; set metadata.maintainer_url to the upstream repository and metadata.distributor/distributor_url to your publisher. Resolve attribution TODOs before release",
55
+ }];
56
+ }
package/dist/validate.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { upstreamAttributionWarnings } from './upstream-attribution.js';
1
2
  import { existsSync } from "node:fs";
2
3
  import { join } from "node:path";
3
4
  import { getErrorCode } from "./error-registry.js";
@@ -36,6 +37,6 @@ export function validateProject(project, _options = {}) {
36
37
  "Correct the schema-2 declaration; see the SDK Compose support matrix.",
37
38
  })),
38
39
  }
39
- : { ok: true, errors: [] };
40
+ : { ok: true, errors: [], warnings: upstreamAttributionWarnings(project.root) };
40
41
  }
41
42
  export { findProjectRoot } from "./discovery.js";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@synopackageland/cli",
3
- "version": "0.30.0",
3
+ "version": "0.32.0",
4
4
  "description": "Syno Package Land Developer Kit CLI",
5
5
  "type": "module",
6
6
  "bin": {
@@ -27,7 +27,7 @@
27
27
  },
28
28
  "dependencies": {
29
29
  "@napi-rs/keyring": "^2.1.1",
30
- "@synopackageland/templates": "~0.30.0",
30
+ "@synopackageland/templates": "~0.31.0",
31
31
  "ajv": "^8.17.1",
32
32
  "commander": "^13.1.0",
33
33
  "json-canonicalize": "^3.0.1",
@@ -96,6 +96,11 @@
96
96
  "pattern": "^https://",
97
97
  "maxLength": 2048
98
98
  }
99
+ },
100
+ "upstream": {
101
+ "type": "string",
102
+ "maxLength": 2048,
103
+ "pattern": "^https?://"
99
104
  }
100
105
  }
101
106
  }
@@ -188,6 +188,11 @@
188
188
  "type": "object",
189
189
  "additionalProperties": false,
190
190
  "properties": {
191
+ "upstream": {
192
+ "type": "string",
193
+ "maxLength": 2048,
194
+ "pattern": "^https?://"
195
+ },
191
196
  "changelog": {
192
197
  "oneOf": [
193
198
  {
package/skills/SKILL.md CHANGED
@@ -5,7 +5,7 @@ description: Initialize, extend, check, build, inspect, and deploy Synology appl
5
5
 
6
6
  # Independent Synology SPKs
7
7
 
8
- CLI 0.27.x installs templates through `~0.27.0`. A templates patch needs only a
8
+ CLI 0.32.0 installs templates through `~0.31.0`. A templates patch needs only a
9
9
  templates release; npm install/update selects a patch in that minor line. The CLI
10
10
  uses the locally installed templates and reads their SDK pin for defaults; it
11
11
  never downloads templates at runtime. Preserve the lockfile and use `npm ci` for
@@ -22,6 +22,14 @@ Use this guide to initialize, build and inspect own applications or upstream OSS
22
22
 
23
23
  For an existing open-source App, first read [reference/upstream-fork.md](reference/upstream-fork.md): upstream fork/history + untouched upstream files + all packaging in `synology/`, official container images pinned by tag/index digest, native official release binaries pinned by SHA-256 or a source build hook, and a single tag-merge upgrade command. Use `init --upstream-fork`; never rewrite the App as a component. Integrated upstream Apps require direct DSM administrator entry without a separate App password or an OIDC/SSO server: supported trusted headers first, disabling native login second; the shipped OpenList pattern uses the existing SDK 0.17.3 token adapter (`mode: token`, `access: dsm-admins`, `entry: admin-only`) with server-side Authorization injection. All DSM admins share one upstream admin; token reset requires package restart; WebDAV retains its native credentials.
24
24
 
25
+ **If you package someone else's open-source project, set `maintainer` to the
26
+ original project/owner (prefer the project's own team/org name, else the owner's
27
+ display name, else login), `metadata.maintainer_url` to the upstream repository,
28
+ and yourself as `metadata.distributor` / `metadata.distributor_url`.** Only your
29
+ own Apps use your publisher as maintainer. Pass upstream and publisher attribution
30
+ to `init --upstream-fork`; resolve TODOs and review `check` warnings before release.
31
+ See the scaffold steps and release checklist in [reference/upstream-fork.md](reference/upstream-fork.md).
32
+
25
33
  For upstream OSS packaging, leave `ctl` absent from the manifest by default. Ask whether the user wants AI control; implement it only when explicitly requested. Read [reference/ctl.md](reference/ctl.md) for the required `ctl-plan`, UI/API coverage checklist and asynchronous-operation rules. Own Apps retain the native template's ctl defaults, with commands covering their main features; status-only coverage is incomplete.
26
34
 
27
35
  Start with `synopkgland init -t native-go`, `synopkgland init -t binary`, or `synopkgland init -t compose`. Read reference/create.md or reference/wrap.md, then reference/deploy.md. Native CGI session checks belong to the package. For wrapping upstream OSS with DSM accounts, read [reference/identity-gateway.md](reference/identity-gateway.md) first. For all Compose work, read [reference/compose.md](reference/compose.md): schema-2 upstream files, generated override, SDK planner, fixed package identity and current DSM blockers. Containers do not inherit a signed-in user's File Station ACL. Existing Shared Folder mounts are blocked until official protected ACL/project control is verified.
@@ -247,7 +247,7 @@ declared availability readiness, LAN isolation/egress, protected grant lifecycle
247
247
  ACLs, two-user visibility and clean install/upgrade/reboot/stop/uninstall before
248
248
  calling the package supported on a NAS. No NAS is accessed by the build gate.
249
249
 
250
- The unified Compose contract uses SDK 0.27.x with CLI 0.27.x and locally installed templates from `~0.27.0`. Templates patches can ship independently; npm install/update resolves them, and SDK defaults come from the installed templates. SDK minor upgrades do not require a CLI release when they declare supported executor manifest format 1; the resolved SDK owns the release mapping and verified payload digests. The CLI does not fetch templates at runtime. The legacy packaging route and bridge publish mode are removed.
250
+ The unified Compose contract uses SDK defaults from locally installed templates `~0.31.0` with CLI 0.32.0. Templates patches can ship independently; npm install/update resolves them, and SDK defaults come from the installed templates. SDK minor upgrades do not require a CLI release when they declare supported executor manifest format 1; the resolved SDK owns the release mapping and verified payload digests. The CLI does not fetch templates at runtime. The legacy packaging route and bridge publish mode are removed.
251
251
 
252
252
  Primary override files, and each included override set, must share a directory. Cross-directory override bases are rejected before loader I/O. Included implicit .env files are confined, inventoried and preserved; symlinks are rejected.
253
253