@synopackageland/templates 0.0.0-stage → 0.9.1

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 (109) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +62 -2
  3. package/package.json +11 -4
  4. package/templates/_shared/AGENTS.md +149 -0
  5. package/templates/_shared/CLAUDE.md +1 -0
  6. package/templates/_shared/GO-LICENSE +27 -0
  7. package/templates/_shared/GO-PATENTS +22 -0
  8. package/templates/_shared/LICENSES/TEMPLATE.txt +21 -0
  9. package/templates/_shared/third-party-notices.mjs +55 -0
  10. package/templates/binary/LICENSES/GO-LICENSE +27 -0
  11. package/templates/binary/LICENSES/GO-PATENTS +22 -0
  12. package/templates/binary/dsm-package.yaml +46 -0
  13. package/templates/binary/example/main.go +16 -0
  14. package/templates/binary/gitignore +2 -0
  15. package/templates/binary/i18n/messages/de.json +4 -0
  16. package/templates/binary/i18n/messages/en.json +4 -0
  17. package/templates/binary/i18n/messages/es.json +4 -0
  18. package/templates/binary/i18n/messages/fr.json +4 -0
  19. package/templates/binary/i18n/messages/it.json +4 -0
  20. package/templates/binary/i18n/messages/ja.json +4 -0
  21. package/templates/binary/i18n/messages/ko.json +4 -0
  22. package/templates/binary/i18n/messages/zh-CN.json +4 -0
  23. package/templates/binary/i18n/messages/zh-TW.json +4 -0
  24. package/templates/binary/ui/app.css +4 -0
  25. package/templates/binary/ui/app.js +32 -0
  26. package/templates/compose/compose.yaml +8 -0
  27. package/templates/compose/synology-app.yaml +16 -0
  28. package/templates/native-go/README.md +372 -0
  29. package/templates/native-go/backend/THIRD_PARTY_NOTICES.txt +53 -0
  30. package/templates/native-go/backend/cmd/app/main.go +101 -0
  31. package/templates/native-go/backend/cmd/background-worker/main.go +38 -0
  32. package/templates/native-go/backend/go.mod +9 -0
  33. package/templates/native-go/backend/go.sum +4 -0
  34. package/templates/native-go/backend/internal/api/permissions.go +26 -0
  35. package/templates/native-go/backend/internal/api/permissions.json +5 -0
  36. package/templates/native-go/backend/internal/api/permissions_test.go +30 -0
  37. package/templates/native-go/backend/internal/app/background.go +249 -0
  38. package/templates/native-go/backend/internal/app/background_none.go +8 -0
  39. package/templates/native-go/backend/internal/app/background_owner.go +38 -0
  40. package/templates/native-go/backend/internal/app/background_owner_test.go +163 -0
  41. package/templates/native-go/backend/internal/app/background_schedule.go +69 -0
  42. package/templates/native-go/backend/internal/app/background_schedule_test.go +62 -0
  43. package/templates/native-go/backend/internal/app/background_test.go +320 -0
  44. package/templates/native-go/backend/internal/app/delegate_config.go +57 -0
  45. package/templates/native-go/backend/internal/app/delegate_config_test.go +36 -0
  46. package/templates/native-go/backend/internal/app/delegate_none.go +5 -0
  47. package/templates/native-go/backend/internal/app/identity.go +20 -0
  48. package/templates/native-go/backend/internal/app/identity_test.go +15 -0
  49. package/templates/native-go/backend/internal/app/migrations.go +41 -0
  50. package/templates/native-go/backend/internal/app/migrations_test.go +41 -0
  51. package/templates/native-go/backend/internal/app/portal.go +30 -0
  52. package/templates/native-go/backend/internal/app/portal_test.go +55 -0
  53. package/templates/native-go/backend/internal/app/root_none.go +9 -0
  54. package/templates/native-go/backend/internal/app/routes.go +137 -0
  55. package/templates/native-go/backend/internal/app/routes_test.go +207 -0
  56. package/templates/native-go/backend/internal/app/session_files.go +9 -0
  57. package/templates/native-go/backend/internal/app/session_files_test.go +14 -0
  58. package/templates/native-go/backend/internal/app/shared_folder.go +144 -0
  59. package/templates/native-go/backend/internal/app/shared_folder_test.go +158 -0
  60. package/templates/native-go/dsm-package.yaml +71 -0
  61. package/templates/native-go/gitignore +1 -0
  62. package/templates/native-go/i18n/glossary.json +663 -0
  63. package/templates/native-go/i18n/messages/de.json +29 -0
  64. package/templates/native-go/i18n/messages/en.json +29 -0
  65. package/templates/native-go/i18n/messages/es.json +29 -0
  66. package/templates/native-go/i18n/messages/fr.json +29 -0
  67. package/templates/native-go/i18n/messages/it.json +29 -0
  68. package/templates/native-go/i18n/messages/ja.json +29 -0
  69. package/templates/native-go/i18n/messages/ko.json +29 -0
  70. package/templates/native-go/i18n/messages/zh-CN.json +29 -0
  71. package/templates/native-go/i18n/messages/zh-TW.json +29 -0
  72. package/templates/native-go/ui/app.css +45 -0
  73. package/templates/native-go/ui/app.js +111 -0
  74. package/templates/native-go-delegate/backend/cmd/background-worker/main.go +49 -0
  75. package/templates/native-go-delegate/backend/internal/app/delegate.go +25 -0
  76. package/templates/native-go-delegate/backend/internal/app/delegate_on.go +5 -0
  77. package/templates/native-go-delegate/backend/internal/app/delegate_routes_test.go +31 -0
  78. package/templates/native-go-delegate/frontend/src/CapabilityPanels.vue +32 -0
  79. package/templates/native-go-delegate/frontend/test/capability-panels.test.ts +77 -0
  80. package/templates/native-go-jobs/backend/internal/app/background_on.go +7 -0
  81. package/templates/native-go-jobs/backend/internal/app/background_routes_test.go +87 -0
  82. package/templates/native-go-jobs/frontend/test/background-page.test.ts +20 -0
  83. package/templates/native-go-root-ops/backend/internal/app/executor.go +128 -0
  84. package/templates/native-go-root-ops/backend/internal/app/executor_test.go +237 -0
  85. package/templates/native-go-root-ops/root/whoami.sh +3 -0
  86. package/templates/native-go-vue/README.md +51 -0
  87. package/templates/native-go-vue/frontend/DESIGN.md +61 -0
  88. package/templates/native-go-vue/frontend/package-lock.json +2550 -0
  89. package/templates/native-go-vue/frontend/package.json +23 -0
  90. package/templates/native-go-vue/frontend/src/App.vue +90 -0
  91. package/templates/native-go-vue/frontend/src/CapabilityPanels.vue +6 -0
  92. package/templates/native-go-vue/frontend/src/i18n/de.json +20 -0
  93. package/templates/native-go-vue/frontend/src/i18n/en.json +20 -0
  94. package/templates/native-go-vue/frontend/src/i18n/es.json +20 -0
  95. package/templates/native-go-vue/frontend/src/i18n/fr.json +20 -0
  96. package/templates/native-go-vue/frontend/src/i18n/it.json +20 -0
  97. package/templates/native-go-vue/frontend/src/i18n/ja.json +20 -0
  98. package/templates/native-go-vue/frontend/src/i18n/ko.json +20 -0
  99. package/templates/native-go-vue/frontend/src/i18n/zh-CN.json +20 -0
  100. package/templates/native-go-vue/frontend/src/i18n/zh-TW.json +20 -0
  101. package/templates/native-go-vue/frontend/src/i18n.ts +13 -0
  102. package/templates/native-go-vue/frontend/src/main.ts +33 -0
  103. package/templates/native-go-vue/frontend/src/style.css +27 -0
  104. package/templates/native-go-vue/frontend/test/apps.test.ts +57 -0
  105. package/templates/native-go-vue/frontend/test/native-controls.test.ts +27 -0
  106. package/templates/native-go-vue/frontend/test/shell-i18n.test.ts +50 -0
  107. package/templates/native-go-vue/frontend/tsconfig.json +1 -0
  108. package/templates/native-go-vue/frontend/vite.config.ts +11 -0
  109. package/templates/native-go-vue/gitignore +2 -0
@@ -0,0 +1,7 @@
1
+ package app
2
+
3
+ import "github.com/synopackageland/syno-pkg-land-sdk/api"
4
+
5
+ // backgroundRoutes serves the owner-scoped background job API. Jobs run
6
+ // in the package-user worker unit (conf/systemd), never as root.
7
+ func (cfg Config) backgroundRoutes() []api.Route { return BackgroundRoutes(cfg.DataDir) }
@@ -0,0 +1,87 @@
1
+ package app
2
+
3
+ import (
4
+ "context"
5
+ "errors"
6
+ "github.com/synopackageland/syno-pkg-land-sdk/api"
7
+ "github.com/synopackageland/syno-pkg-land-sdk/background"
8
+ "net/http/httptest"
9
+ "os"
10
+ "path/filepath"
11
+ "strings"
12
+ "syscall"
13
+ "testing"
14
+ "time"
15
+ )
16
+
17
+ func init() {
18
+ // The generic route tests need valid params for every background route.
19
+ // The fixture has no DSM cookie; import is refused before reading a file.
20
+ // No jobs or schedules exist in its fresh data directory.
21
+ const id = `{"id":"missing"}`
22
+ sampleParams["bg.jobs.purge"] = `{"owner":{"user":"missing","uid":"9999"}}`
23
+ sampleParams["bg.schedules.list"] = `{}`
24
+ sampleParams["bg.schedules.create"] = `{"key":"schedule","jobId":"missing","intervalSeconds":3600,"consent":true}`
25
+ sampleParams["bg.schedules.disable"] = id
26
+ sampleParams["bg.schedules.delete"] = id
27
+ sampleParams["bg.schedules.enable"] = `{"id":"missing","consent":true}`
28
+ sampleOutcome["bg.schedules.create"] = "not_found"
29
+ sampleOutcome["bg.schedules.disable"] = "not_found"
30
+ sampleOutcome["bg.schedules.delete"] = "not_found"
31
+ sampleOutcome["bg.schedules.enable"] = "not_found"
32
+ sampleParams["bg.status"] = `{}`
33
+ sampleParams["bg.browse"] = `{}`
34
+ sampleParams["bg.jobs.submit"] = `{"key":"k1","folder":"/share","relative":"a.bin"}`
35
+ sampleParams["bg.jobs.list"] = `{}`
36
+ sampleParams["bg.jobs.get"] = id
37
+ sampleParams["bg.jobs.cancel"] = id
38
+ sampleParams["bg.jobs.result"] = id
39
+ sampleOutcome["bg.browse"] = "session_required"
40
+ sampleOutcome["bg.jobs.submit"] = "session_required"
41
+ sampleOutcome["bg.jobs.get"] = "not_found"
42
+ sampleOutcome["bg.jobs.cancel"] = "not_found"
43
+ sampleOutcome["bg.jobs.result"] = "not_found"
44
+ }
45
+
46
+ func TestBackgroundBusyErrorCode(t *testing.T) {
47
+ err := backgroundError(background.ErrBusy)
48
+ var e *api.Error
49
+ if !errors.As(err, &e) || e.Code != "jobs_busy" {
50
+ t.Fatalf("busy mapping: %v", err)
51
+ }
52
+ }
53
+
54
+ func TestBackgroundRequestSubmitBusyAndProductionConstruction(t *testing.T) {
55
+ e := newBgEnv(t)
56
+ dir := filepath.Join(t.TempDir(), "background")
57
+ e.store = background.NewRequestStore(dir)
58
+ if _, err := e.store.List(context.Background()); err != nil {
59
+ t.Fatal(err)
60
+ }
61
+ f, err := os.OpenFile(filepath.Join(dir, "lock"), os.O_RDWR, 0)
62
+ if err != nil {
63
+ t.Fatal(err)
64
+ }
65
+ defer f.Close()
66
+ if err := syscall.Flock(int(f.Fd()), syscall.LOCK_EX|syscall.LOCK_NB); err != nil {
67
+ t.Fatal(err)
68
+ }
69
+ defer syscall.Flock(int(f.Fd()), syscall.LOCK_UN)
70
+ start := time.Now()
71
+ code, out := e.call(alice, "bg.jobs.submit", bgSample["bg.jobs.submit"])
72
+ if code != "jobs_busy" || time.Since(start) > background.RequestLockTimeout+time.Second {
73
+ t.Fatalf("busy submit: %s %+v", code, out)
74
+ }
75
+ // The actual production assembly must use the same bounded wait policy.
76
+ srv, err := api.NewServerWithPermissions(alice, []byte(`{"schema":1,"default":"administrators","methods":{}}`), BackgroundRoutes(filepath.Dir(dir))...)
77
+ if err != nil {
78
+ t.Fatal(err)
79
+ }
80
+ r := httptest.NewRequest("POST", "/api.cgi", strings.NewReader(`{"method":"bg.jobs.list","params":{}}`))
81
+ r.Header.Set("Content-Type", "application/json")
82
+ w := httptest.NewRecorder()
83
+ srv.ServeHTTP(w, r)
84
+ if !strings.Contains(w.Body.String(), `"jobs_busy"`) {
85
+ t.Fatal(w.Body.String())
86
+ }
87
+ }
@@ -0,0 +1,20 @@
1
+ // @vitest-environment jsdom
2
+ import {expect,test,vi} from 'vitest'
3
+ import {nextTick} from 'vue'
4
+ import {mount} from '../src/main'
5
+ import type {Context} from '@synopackageland/sdk'
6
+ async function settle(){for(let i=0;i<20;i++){await Promise.resolve();await nextTick()}}
7
+ // The CLI turns the background page on for jobs (and delegate) projects only.
8
+ test('a jobs project mounts the background panel for the signed-in user',async()=>{
9
+ const call=vi.fn(async(method:string)=>{
10
+ if(method==='app.status')return {package:'Jobs',version:'1',user:'alice',admin:false}
11
+ throw {code:'unknown_method'}
12
+ })
13
+ const root=document.createElement('div');document.body.append(root)
14
+ const app=mount(root,{locale:'en',call,reopen:()=>()=>{}} as unknown as Context)
15
+ try{
16
+ await settle()
17
+ expect(call.mock.calls.some(([method])=>method.startsWith('bg.'))).toBe(true)
18
+ expect(root.querySelector('[data-page="background"]')).not.toBeNull()
19
+ }finally{app.unmount();root.remove()}
20
+ })
@@ -0,0 +1,128 @@
1
+ package app
2
+
3
+ import (
4
+ "context"
5
+ "encoding/json"
6
+ "errors"
7
+ "net/http"
8
+ "os"
9
+ "path/filepath"
10
+ "strings"
11
+ "time"
12
+
13
+ "github.com/synopackageland/syno-pkg-land-sdk/api"
14
+ "github.com/synopackageland/syno-pkg-land-sdk/delegate"
15
+ )
16
+
17
+ func (cfg Config) rootRoutes() []api.Route {
18
+ return []api.Route{
19
+ api.Handle("executor.control", cfg.executorControl),
20
+ api.Handle("executor.status", cfg.executorStatus),
21
+ api.HandleRequest("root.run", cfg.executorRootRun),
22
+ }
23
+ }
24
+
25
+ // API permissions default to administrators for unlisted methods. Check here
26
+ // too: control mutates the fixed package-owned control file before DSM run.
27
+ func (cfg Config) executorControl(_ context.Context, caller api.Caller, raw json.RawMessage) (any, error) {
28
+ if !caller.Admin() {
29
+ return nil, errors.New("admin_required")
30
+ }
31
+ var in struct {
32
+ Action string `json:"action"`
33
+ }
34
+ if err := decodeParams(raw, &in); err != nil {
35
+ return nil, err
36
+ }
37
+ if in.Action != "start" && in.Action != "stop" && in.Action != "purge" {
38
+ return nil, api.BadRequest("executor_control_invalid")
39
+ }
40
+ dir := filepath.Join(cfg.DataDir, "executor")
41
+ if err := os.MkdirAll(dir, 0700); err != nil {
42
+ return nil, err
43
+ }
44
+ info, err := os.Lstat(dir)
45
+ if err != nil || !info.IsDir() || info.Mode()&os.ModeSymlink != 0 {
46
+ return nil, errors.New("executor_control_directory_invalid")
47
+ }
48
+ if err := writeFileAtomic(filepath.Join(dir, "control"), []byte(in.Action)); err != nil {
49
+ return nil, err
50
+ }
51
+ return map[string]bool{"written": true}, nil
52
+ }
53
+
54
+ // The executor closes its socket while paused: the package is stopped, or an
55
+ // upgrade changed the pins and awaits administrator approval.
56
+ func (cfg Config) executorStatus(ctx context.Context, _ api.Caller, _ json.RawMessage) (any, error) {
57
+ var status json.RawMessage
58
+ err := cfg.executor().Call(ctx, "status", nil, nil, &status)
59
+ var refusal *api.Error
60
+ if errors.As(err, &refusal) && refusal.Code == delegate.CodeExecutorUnavailable {
61
+ next := "Start the package."
62
+ if cfg.startRequested() {
63
+ next = "The package is running: an administrator approves background access again in Administrator setup; the executor log names the cause."
64
+ }
65
+ return map[string]any{"ready": false, "state": "paused", "reason": "executor_not_listening", "next": next}, nil
66
+ }
67
+ return status, err
68
+ }
69
+
70
+ // Package start and stop write the lifecycle mailbox; its last request tells a
71
+ // stopped package from a running one whose executor stays paused.
72
+ func (cfg Config) startRequested() bool {
73
+ f, err := os.Open(filepath.Join(cfg.DataDir, "executor", "control"))
74
+ if err != nil {
75
+ return false
76
+ }
77
+ defer f.Close()
78
+ b := make([]byte, 16)
79
+ n, _ := f.Read(b)
80
+ return strings.TrimSpace(string(b[:n])) == "start"
81
+ }
82
+
83
+ func (cfg Config) executorRootRun(ctx context.Context, caller api.Caller, req *http.Request, raw json.RawMessage) (any, error) {
84
+ if !caller.Admin() {
85
+ return nil, errors.New("admin_required")
86
+ }
87
+ var in struct {
88
+ Name string `json:"name"`
89
+ }
90
+ if err := decodeParams(raw, &in); err != nil {
91
+ return nil, err
92
+ }
93
+ // Executor independently verifies both session values and administrator identity.
94
+ auth, err := api.DSMSession(req)
95
+ if err != nil {
96
+ return nil, err
97
+ }
98
+ if err := cfg.executorHoldsThisBuild(ctx); err != nil {
99
+ return nil, err
100
+ }
101
+ var out json.RawMessage
102
+ if err := cfg.executor().Call(ctx, "root.run", auth, map[string]string{"name": in.Name}, &out); err != nil {
103
+ return nil, err
104
+ }
105
+ return out, nil
106
+ }
107
+
108
+ // executorHoldsThisBuild refuses, as delegate does, an executor pinned to
109
+ // another build's policy or executor: its approved scripts are not this build's.
110
+ func (cfg Config) executorHoldsThisBuild(ctx context.Context) error {
111
+ var pins delegate.ExecutorStatus
112
+ if err := cfg.executor().Call(ctx, "status", nil, nil, &pins); err != nil {
113
+ return err
114
+ }
115
+ if (cfg.ExecutorDigest != "" && pins.ExecutorDigest != cfg.ExecutorDigest) || (cfg.PolicyDigest != "" && pins.PolicyDigest != cfg.PolicyDigest) {
116
+ return &api.Error{Code: delegate.CodePolicyDenied, Message: "the executor holds another build's approved policy; an administrator approves this build in Administrator setup"}
117
+ }
118
+ return nil
119
+ }
120
+
121
+ // executorSocketBase is a variable so tests can use a temporary directory.
122
+ var executorSocketBase = "/run/synopkgland"
123
+
124
+ // executor keeps the executor's protocol v1 refusal codes (POLICY_DENIED,
125
+ // EXECUTOR_UNAVAILABLE, ...) as the API error code.
126
+ func (cfg Config) executor() *delegate.Client {
127
+ return &delegate.Client{Socket: filepath.Join(executorSocketBase, cfg.PackageID, "executor.sock"), Timeout: 30 * time.Second}
128
+ }
@@ -0,0 +1,237 @@
1
+ package app
2
+
3
+ import (
4
+ "bufio"
5
+ "encoding/json"
6
+ "net"
7
+ "os"
8
+ "path/filepath"
9
+ "strings"
10
+ "sync"
11
+ "testing"
12
+
13
+ appapi "__PACKAGE__/internal/api"
14
+ )
15
+
16
+ func init() {
17
+ sampleParams["executor.control"] = `{"action":"stop"}`
18
+ sampleParams["executor.status"] = `{}`
19
+ sampleParams["root.run"] = `{"name":"whoami"}`
20
+ sampleOutcome["root.run"] = "session_required"
21
+ }
22
+
23
+ func TestExecutorControlAcceptsOnlyFixedActions(t *testing.T) {
24
+ cfg := testConfig(t)
25
+ for _, action := range []string{"start", "stop", "purge"} {
26
+ code, out := call(t, cfg, alice, "executor.control", `{"action":"`+action+`"}`)
27
+ if code != "" {
28
+ t.Fatalf("%s: %s %v", action, code, out)
29
+ }
30
+ b, err := os.ReadFile(filepath.Join(cfg.DataDir, "executor/control"))
31
+ if err != nil || string(b) != action {
32
+ t.Fatalf("control was not written: %s %v", b, err)
33
+ }
34
+ }
35
+ for _, raw := range []string{`{"action":"start;id"}`, `{"action":"start\nstop"}`, `{"action":""}`, `{"action":"stop","path":"/etc/passwd"}`} {
36
+ if code, _ := call(t, cfg, alice, "executor.control", raw); code != "bad_request" {
37
+ t.Fatalf("accepted %s: %s", raw, code)
38
+ }
39
+ }
40
+ }
41
+
42
+ func TestExecutorControlRejectsSymlinkDirectory(t *testing.T) {
43
+ cfg := testConfig(t)
44
+ outside := t.TempDir()
45
+ if err := os.Symlink(outside, filepath.Join(cfg.DataDir, "executor")); err != nil {
46
+ t.Fatal(err)
47
+ }
48
+ if code, _ := call(t, cfg, alice, "executor.control", `{"action":"stop"}`); code == "" {
49
+ t.Fatal("accepted symlink")
50
+ }
51
+ if _, err := os.Stat(filepath.Join(outside, "control")); !os.IsNotExist(err) {
52
+ t.Fatal("wrote outside data directory")
53
+ }
54
+ }
55
+
56
+ func TestExecutorRootRunRequiresCurrentDSMCookie(t *testing.T) {
57
+ t.Setenv("HTTP_COOKIE", "")
58
+ if code, _ := call(t, testConfig(t), alice, "root.run", `{"name":"whoami"}`); code == "" {
59
+ t.Fatal("root.run accepted absent DSM cookie")
60
+ }
61
+ }
62
+
63
+ // The executor closes its listener while paused (package stopped, or pins
64
+ // awaiting administrator approval). Status then answers, not "internal".
65
+ func TestExecutorStatusReportsAPausedExecutor(t *testing.T) {
66
+ cfg := testConfig(t)
67
+ useExecutorSocketBase(t)
68
+ check := func(name string) {
69
+ t.Helper()
70
+ code, envelope := call(t, cfg, alice, "executor.status", `{}`)
71
+ out, _ := envelope["data"].(map[string]any)
72
+ if code != "" || out["ready"] != false || out["state"] != "paused" || out["reason"] != "executor_not_listening" || out["next"] == "" || out["next"] == nil {
73
+ t.Fatalf("%s: %s %v", name, code, out)
74
+ }
75
+ }
76
+ check("no socket")
77
+ if code, _ := call(t, cfg, bob, "executor.status", `{}`); code != "" {
78
+ t.Fatal("authenticated user cannot read a paused status", code)
79
+ }
80
+ dir := filepath.Join(executorSocketBase, cfg.PackageID)
81
+ os.MkdirAll(dir, 0700)
82
+ ln, err := net.ListenUnix("unix", &net.UnixAddr{Name: filepath.Join(dir, "executor.sock"), Net: "unix"})
83
+ if err != nil {
84
+ t.Fatal(err)
85
+ }
86
+ ln.SetUnlinkOnClose(false)
87
+ ln.Close()
88
+ check("closed listener")
89
+ }
90
+
91
+ // next follows the last lifecycle request: a running package needs approval
92
+ // again, not a start.
93
+ func TestExecutorStatusNextFollowsTheLifecycleRequest(t *testing.T) {
94
+ cfg := testConfig(t)
95
+ useExecutorSocketBase(t)
96
+ next := func() string {
97
+ t.Helper()
98
+ _, envelope := call(t, cfg, alice, "executor.status", `{}`)
99
+ out, _ := envelope["data"].(map[string]any)
100
+ s, _ := out["next"].(string)
101
+ return s
102
+ }
103
+ if !strings.HasPrefix(next(), "Start the package.") {
104
+ t.Fatal("no request:", next())
105
+ }
106
+ mailbox := filepath.Join(cfg.DataDir, "executor")
107
+ os.MkdirAll(mailbox, 0700)
108
+ for request, start := range map[string]bool{"stop\n": true, "start\n": false, "start": false} {
109
+ os.WriteFile(filepath.Join(mailbox, "control"), []byte(request), 0600)
110
+ if got := next(); strings.HasPrefix(got, "Start the package") != start || got == "" {
111
+ t.Fatalf("%q: %s", request, got)
112
+ }
113
+ }
114
+ }
115
+
116
+ // fakeExecutor answers each v1 request with replies[op] and records the ops.
117
+ type fakeExecutor struct {
118
+ mu sync.Mutex
119
+ ops []string
120
+ }
121
+
122
+ func (f *fakeExecutor) received() []string {
123
+ f.mu.Lock()
124
+ defer f.mu.Unlock()
125
+ return append([]string(nil), f.ops...)
126
+ }
127
+
128
+ func startFakeExecutor(t *testing.T, cfg Config, replies map[string]string) *fakeExecutor {
129
+ t.Helper()
130
+ useExecutorSocketBase(t)
131
+ dir := filepath.Join(executorSocketBase, cfg.PackageID)
132
+ if err := os.MkdirAll(dir, 0700); err != nil {
133
+ t.Fatal(err)
134
+ }
135
+ ln, err := net.Listen("unix", filepath.Join(dir, "executor.sock"))
136
+ if err != nil {
137
+ t.Fatal(err)
138
+ }
139
+ t.Cleanup(func() { ln.Close() })
140
+ f := &fakeExecutor{}
141
+ go func() {
142
+ for {
143
+ conn, err := ln.Accept()
144
+ if err != nil {
145
+ return
146
+ }
147
+ var req struct{ Op string }
148
+ line, _ := bufio.NewReader(conn).ReadBytes('\n')
149
+ json.Unmarshal(line, &req)
150
+ f.mu.Lock()
151
+ f.ops = append(f.ops, req.Op)
152
+ f.mu.Unlock()
153
+ conn.Write([]byte(replies[req.Op] + "\n"))
154
+ conn.Close()
155
+ }
156
+ }()
157
+ return f
158
+ }
159
+
160
+ // useExecutorSocketBase points the CGI at a short temporary directory: unix
161
+ // socket paths are limited to about 100 bytes.
162
+ func useExecutorSocketBase(t *testing.T) {
163
+ t.Helper()
164
+ previous := executorSocketBase
165
+ base, err := os.MkdirTemp("/tmp", "exec")
166
+ if err != nil {
167
+ t.Fatal(err)
168
+ }
169
+ executorSocketBase = base
170
+ t.Cleanup(func() { executorSocketBase = previous; os.RemoveAll(base) })
171
+ }
172
+
173
+ // rootRun calls root.run as an administrator with a current DSM session.
174
+ func rootRun(t *testing.T, cfg Config, name string) (string, map[string]any) {
175
+ t.Helper()
176
+ permissions, err := appapi.Permissions()
177
+ if err != nil {
178
+ t.Fatal(err)
179
+ }
180
+ req := apiRequest("root.run", `{"name":"`+name+`"}`)
181
+ req.Header.Set("Cookie", "id=session")
182
+ req.Header.Set("X-SYNO-TOKEN", "token")
183
+ return serve(t, cfg, alice, req, permissions)
184
+ }
185
+
186
+ const readyStatus = `{"ok":true,"result":{"executor_digest":"e1","policy_digest":"p1","ready":true,"disabled":false,"version":"1"}}`
187
+
188
+ func pinnedConfig(t *testing.T) Config {
189
+ cfg := testConfig(t)
190
+ cfg.ExecutorDigest, cfg.PolicyDigest = "e1", "p1"
191
+ return cfg
192
+ }
193
+
194
+ func TestExecutorRootRunRunsWhenTheExecutorHoldsThisBuildsPins(t *testing.T) {
195
+ cfg := pinnedConfig(t)
196
+ startFakeExecutor(t, cfg, map[string]string{"status": readyStatus, "root.run": `{"ok":true,"result":{"output":"0\n"}}`})
197
+ code, out := rootRun(t, cfg, "whoami")
198
+ if data, _ := out["data"].(map[string]any); code != "" || data["output"] != "0\n" {
199
+ t.Fatalf("%s %v", code, out)
200
+ }
201
+ }
202
+
203
+ // Like delegate: an executor pinned to another build's policy or executor
204
+ // refuses the operation, it does not run it.
205
+ func TestExecutorRootRunRefusesAnExecutorPinnedToAnotherBuild(t *testing.T) {
206
+ for name, status := range map[string]string{
207
+ "policy": strings.Replace(readyStatus, `"p1"`, `"p2"`, 1),
208
+ "executor": strings.Replace(readyStatus, `"e1"`, `"e2"`, 1),
209
+ } {
210
+ cfg := pinnedConfig(t)
211
+ f := startFakeExecutor(t, cfg, map[string]string{"status": status, "root.run": `{"ok":true,"result":{"output":"0\n"}}`})
212
+ if code, out := rootRun(t, cfg, "whoami"); code != "POLICY_DENIED" {
213
+ t.Errorf("%s digest mismatch: %q %v", name, code, out)
214
+ }
215
+ for _, op := range f.received() {
216
+ if op == "root.run" {
217
+ t.Errorf("%s digest mismatch: root.run reached the executor", name)
218
+ }
219
+ }
220
+ }
221
+ }
222
+
223
+ // Refusals keep the protocol v1 code (spec/protocol/v1/errors.json), never "internal".
224
+ func TestExecutorRootRunReportsTheExecutorsRefusalCode(t *testing.T) {
225
+ cfg := pinnedConfig(t)
226
+ useExecutorSocketBase(t)
227
+ if code, _ := rootRun(t, cfg, "whoami"); code != "EXECUTOR_UNAVAILABLE" {
228
+ t.Errorf("paused or awaiting approval (no socket): %q", code)
229
+ }
230
+ for _, refusal := range []string{"POLICY_DENIED", "INVALID_REQUEST", "EXECUTOR_PAUSED", "ROOT_FAILED", "ADMIN_REQUIRED"} {
231
+ cfg := pinnedConfig(t)
232
+ startFakeExecutor(t, cfg, map[string]string{"status": readyStatus, "root.run": `{"ok":false,"code":"` + refusal + `","message":"refused"}`})
233
+ if code, out := rootRun(t, cfg, "whoami"); code != refusal {
234
+ t.Errorf("%s: %q %v", refusal, code, out)
235
+ }
236
+ }
237
+ }
@@ -0,0 +1,3 @@
1
+ #!/bin/sh
2
+ set -eu
3
+ id -u
@@ -0,0 +1,51 @@
1
+ # Native Go with Vue
2
+
3
+ Edit `frontend/src/App.vue` and `main.ts` to build a DSM-window app with the
4
+ public `@synopackageland/ui` components and embedded theme. The shell, sign-in,
5
+ files, portal, jobs and delegate components come from `@synopackageland/sdk/vue`
6
+ (types from `@synopackageland/sdk`); its version must equal the
7
+ `github.com/synopackageland/syno-pkg-land-sdk` version in `backend/go.mod`. See
8
+ <https://github.com/synopackageland/syno-pkg-land-sdk> for the components and their props.
9
+
10
+ ```sh
11
+ cd frontend
12
+ npm ci
13
+ npm test
14
+ npm run build
15
+ ```
16
+
17
+ The base app reads package status and edits the package greeting through
18
+ `ctx.call`. Each window has its own Vue mount, request state and host
19
+ subscriptions; unmount disposes subscriptions and ignores late responses.
20
+
21
+ `--with root-ops` adds the existing DSM approval gate for fixed `root/*.sh`
22
+ operations. `--with jobs` adds package-account jobs and schedules; it combines with root-ops and files. `--with files` enables current-session File Station access,
23
+ and the background picker uses the retained session provider.
24
+
25
+ With `portal:` declared (`synopkgland add portal`, executor Apps only), the status page shows
26
+ the SDK's `PortalSettings`: the DSM Application Portal link, the alias form
27
+ for administrators, and the Control Panel shortcut.
28
+
29
+ Use public library controls, the embedded theme, per-app modal scope and unique
30
+ Vue ID prefixes. Backend route permissions and DSM checks remain authoritative.
31
+ Generated build and unit tests do not establish NAS runtime acceptance.
32
+
33
+ The manifest is dsm-package.yaml schema 2: backend.kind: go and capabilities.files: session, jobs: true, identity: standalone, rootOps: true. Use --with login to declare standalone identity. Existing projects use synopkgland add <cap>...; app entrypoints stay app-owned, SDK conflicts are refused. check, inspect, build, deploy and status accept --json.
34
+
35
+ Use the installed `@synopackageland/ui`; package.json pins its version.
36
+ `AppRoot` defaults to app modal scope and stays bounded at `height: 100%`;
37
+ `AppLayout wide-layout="window"` keeps the DSM shell in window mode even when
38
+ maximized. Only whole-page web/Portal apps should opt into document scope or
39
+ `wide-layout="web"`. The DSM host sets the 700px minimum window width.
40
+ Import `themes/dsm7-embedded.css` and assign each mount a unique
41
+ `app.config.idPrefix` before mounting.
42
+
43
+ Start with AGENTS.md, `synopkgland skill list` and `synopkgland skill path`.
44
+ Read `synopkgland skill show ui-vue` for direct Vue component work and
45
+ `synopkgland skill show ui-json` for optional declarative specs and their
46
+ validate/render workflow. The existing host/backend lifecycle remains in place.
47
+
48
+ For UI discovery, run `npx --no syno-pkg-land-ui catalog --json` and
49
+ `npx --no syno-pkg-land-ui describe Button --json` from the installed frontend.
50
+ Read its prompt and exported Vue docs/types before choosing component APIs.
51
+ Use `syno-pkg-land-ui` directly in npm scripts; keep AI guidance version-independent.
@@ -0,0 +1,61 @@
1
+ ---
2
+ name: Native DSM Vue template
3
+ description: Existing package settings UI using the installed @synopackageland/ui.
4
+ typography:
5
+ body:
6
+ fontFamily: "var(--ui-font-sans)"
7
+ fontSize: "13px"
8
+ lineHeight: 1.5
9
+ title:
10
+ fontSize: "20px"
11
+ fontWeight: 600
12
+ state:
13
+ fontSize: "15px"
14
+ fontWeight: 600
15
+ spacing:
16
+ actions: "8px"
17
+ heading-gap: "16px"
18
+ section-gap: "24px"
19
+ ---
20
+
21
+ # Design System: Native DSM Vue template
22
+
23
+ Read `AGENTS.md` at the project root and `synopkgland skill show ui-vue` before UI edits.
24
+
25
+ For UI discovery, run `npx --no syno-pkg-land-ui catalog --json` and
26
+ `npx --no syno-pkg-land-ui describe Button --json` from the installed frontend.
27
+ Read its prompt and exported Vue docs/types before choosing component APIs.
28
+ Use `syno-pkg-land-ui` directly in npm scripts; keep AI guidance version-independent.
29
+
30
+ ## Overview
31
+
32
+ The template runs inside a DSM package window. `AppShell` provides status and
33
+ optional background navigation. The base page shows package status and a greeting
34
+ editor; the root overlay adds its existing fixed-operation authorization gate.
35
+
36
+ Use the installed `@synopackageland/ui` controls and `themes/dsm7-embedded.css`.
37
+ The theme owns colors, typography, spacing and dialogs. Keep per-app modal scope,
38
+ a unique Vue ID prefix and vertical scrolling at the DSM minimum window size.
39
+
40
+ Refresh and save expose busy state and localized errors. Reopen refreshes status.
41
+ Each mount owns its state; unmount unsubscribes from host events and ignores late
42
+ responses. Backend route policies decide access.
43
+
44
+ ### Opt-in background schedules (S1-a)
45
+
46
+ The `native-go-jobs` overlay extends this **Operate** surface within the inherited `AppRoot`, embedded theme, `SettingsSection`, Button and Callout system. It introduces no new palette or global tokens.
47
+
48
+ - **Private-copy consent:** “Schedule this copy” is available for the signed-in owner's retained, successful, unscheduled job copy. “Private copy: {name}” identifies the input; “Repeat every” offers “Hour”, “Day” and “Week”. “Create schedule” stays disabled until the user checks “I agree to keep and reprocess this fixed copy as the package user. No new source files or personal ACLs are read.” The help explains the eight-schedule limit and that missed runs are recorded, never replayed.
49
+ - **Ownership and recovery:** preserve user-and-UID owner isolation. Administrator rows expose “Owner: {user} (UID {uid})”. A worker change shows “The worker revision changed. An administrator must review and explicitly enable this schedule.” Only administrators, while background mode is on, see “Enable schedule”. Its confirmation names the exact private copy: “Enable the schedule for {name} with the current worker revision? I agree to reprocess this fixed copy. Past runs will not be replayed.” Actions are “Confirm enable” and “Cancel”.
50
+ - **Layout:** schedule name/state, owner, next run, missed runs and warnings stack above a grouped, wrapping action row. Long names and copy wrap within the available width. Keep the inherited vertical scrolling at the minimum window size; do not squeeze schedules into one viewport. The labeled select and consent checkbox retain visible keyboard focus using existing theme tokens.
51
+ - **Errors and cleanup:** danger alerts map known error codes to localized safe copy, with a localized fallback rather than raw server text. The catalogs cover nine locales. Preserve per-mount disposal: stop polling, dispose the picker, revoke result download URLs and prevent pending responses from restoring disposed state. Deletion confirmation explains that queued jobs cancel, running jobs drain and finished results remain.
52
+
53
+ Evidence: `.herdr-project/syno-package-land-sdk-t-0129/library/ui-desktop.png` and `ui-minimum.png` are local component-fixture captures. The finish review disposition is **ship**, with all three material findings resolved. A fresh `normal1` NAS browser readback passed before the final UI adjustments; these captures validate the final adjustments locally and do not establish final DSM visual acceptance.
54
+
55
+ ## Do's and Don'ts
56
+
57
+ - Do use existing public UI components, embedded theme tokens and state/recovery copy.
58
+ - Do retain permission gating, readable loading/empty/error states and per-instance cleanup.
59
+ - Do use the library's `Select`, `Checkbox`, `Input`, `Textarea`, `Form`, `FormItem`, `FilePicker` (`variant="compact"`, in the app locale) and `Link` for controls; there is no native `<input>` or `<a>`. Leave `Form` on its automatic direction (labels stack below 520px of container width) and let a `FormItem` bind its control, with no `direction="vertical"`, `role="group"` or `field.controlId`.
60
+ - Do not infer authority from frontend observations or scheduler history.
61
+ - Don't introduce a separate palette, component system or modal workaround around the library contract.