@rubytech/create-realagent-code 0.1.21 → 0.1.23

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 (90) hide show
  1. package/dist/__tests__/samba-provision.test.js +202 -0
  2. package/dist/index.js +127 -73
  3. package/dist/samba-provision.js +215 -0
  4. package/dist/uninstall.js +160 -3
  5. package/package.json +1 -1
  6. package/payload/platform/plugins/docs/references/deployment.md +20 -0
  7. package/payload/platform/plugins/email/references/email-reference.md +4 -4
  8. package/payload/platform/plugins/scheduling/PLUGIN.md +1 -1
  9. package/payload/platform/plugins/workflows/PLUGIN.md +2 -2
  10. package/payload/platform/plugins/workflows/skills/workflow-manager/SKILL.md +1 -1
  11. package/payload/platform/templates/agents/admin/IDENTITY.md +12 -18
  12. package/payload/platform/templates/specialists/agents/personal-assistant.md +1 -1
  13. package/payload/platform/templates/specialists/agents/project-manager.md +1 -1
  14. package/payload/server/public/assets/{Checkbox-B79fVxpA.js → Checkbox-D1OQD43b.js} +1 -1
  15. package/payload/server/public/assets/admin-czNBxWor.js +216 -0
  16. package/payload/server/public/assets/{architectureDiagram-Q4EWVU46-D8e59YJ0.js → architectureDiagram-Q4EWVU46-BcwgT80u.js} +1 -1
  17. package/payload/server/public/assets/{blockDiagram-DXYQGD6D-CxaDkc0A.js → blockDiagram-DXYQGD6D-BMSyZUQA.js} +1 -1
  18. package/payload/server/public/assets/{brand-Cg9t5U6J.css → brand-2cku8WFs.css} +1 -1
  19. package/payload/server/public/assets/{brand-jT16ErmC.js → brand-CSQuxS9w.js} +1 -1
  20. package/payload/server/public/assets/{c4Diagram-AHTNJAMY-D0PAvq-q.js → c4Diagram-AHTNJAMY-DPRGY1jJ.js} +1 -1
  21. package/payload/server/public/assets/channel-fxEghWew.js +1 -0
  22. package/payload/server/public/assets/{chunk-336JU56O-B-CXn-Et.js → chunk-336JU56O-B7oQ3g1c.js} +2 -2
  23. package/payload/server/public/assets/{chunk-426QAEUC-BLzCQHKA.js → chunk-426QAEUC-C1P0yFXw.js} +1 -1
  24. package/payload/server/public/assets/{chunk-4TB4RGXK-Bql1UwLT.js → chunk-4TB4RGXK-LI7kOJd0.js} +1 -1
  25. package/payload/server/public/assets/{chunk-5FUZZQ4R-CQK7jBtX.js → chunk-5FUZZQ4R-CXQRGTQE.js} +1 -1
  26. package/payload/server/public/assets/{chunk-5PVQY5BW-AJc1-lvX.js → chunk-5PVQY5BW-NSyzpXRy.js} +1 -1
  27. package/payload/server/public/assets/{chunk-EDXVE4YY-Cf3E3THL.js → chunk-EDXVE4YY-voNwxbDs.js} +1 -1
  28. package/payload/server/public/assets/{chunk-ENJZ2VHE-BNx6z6hJ.js → chunk-ENJZ2VHE-CMEMPzYY.js} +1 -1
  29. package/payload/server/public/assets/{chunk-ICPOFSXX-DBUEFs2-.js → chunk-ICPOFSXX-hEbwu-pe.js} +1 -1
  30. package/payload/server/public/assets/{chunk-OYMX7WX6-Csx2p315.js → chunk-OYMX7WX6-DxskDrLs.js} +1 -1
  31. package/payload/server/public/assets/{chunk-U2HBQHQK-x17h7UYW.js → chunk-U2HBQHQK-D7TKgUo0.js} +1 -1
  32. package/payload/server/public/assets/{chunk-X2U36JSP--Lkl5yjV.js → chunk-X2U36JSP-BvPUQEPm.js} +1 -1
  33. package/payload/server/public/assets/{chunk-YZCP3GAM-C4GsNX8A.js → chunk-YZCP3GAM-BY-RWQUW.js} +1 -1
  34. package/payload/server/public/assets/{chunk-ZZ45TVLE-YrhUPmZc.js → chunk-ZZ45TVLE-DZvOYDY6.js} +1 -1
  35. package/payload/server/public/assets/classDiagram-6PBFFD2Q-BsWzGW0N.js +1 -0
  36. package/payload/server/public/assets/classDiagram-v2-HSJHXN6E-BGVa3h90.js +1 -0
  37. package/payload/server/public/assets/clone-Khvocke2.js +1 -0
  38. package/payload/server/public/assets/{dagre-YVALPG-M.js → dagre-Bt-fpckL.js} +1 -1
  39. package/payload/server/public/assets/{dagre-KV5264BT-D6JU6DW_.js → dagre-KV5264BT-Cnj0mUZl.js} +1 -1
  40. package/payload/server/public/assets/data-DBd-Buhp.js +1 -0
  41. package/payload/server/public/assets/device-url-actions-Bjz3Xzbm.js +33 -0
  42. package/payload/server/public/assets/{diagram-5BDNPKRD-yeO06N5Q.js → diagram-5BDNPKRD-DjLzvOlx.js} +1 -1
  43. package/payload/server/public/assets/{diagram-G4DWMVQ6-DzbVT_BC.js → diagram-G4DWMVQ6-DTfuRd-T.js} +1 -1
  44. package/payload/server/public/assets/{diagram-MMDJMWI5-DwYO5VZF.js → diagram-MMDJMWI5-BaL2mCnx.js} +1 -1
  45. package/payload/server/public/assets/{diagram-TYMM5635-BLUcdkDS.js → diagram-TYMM5635-C5InWY5R.js} +1 -1
  46. package/payload/server/public/assets/{erDiagram-SMLLAGMA-BiEUB19e.js → erDiagram-SMLLAGMA-DO7BXTpn.js} +1 -1
  47. package/payload/server/public/assets/{flowDiagram-DWJPFMVM-TILIKxOp.js → flowDiagram-DWJPFMVM-DDdAKfLf.js} +1 -1
  48. package/payload/server/public/assets/{ganttDiagram-T4ZO3ILL-B7cGzYqT.js → ganttDiagram-T4ZO3ILL-arJD8Utm.js} +1 -1
  49. package/payload/server/public/assets/{gitGraphDiagram-UUTBAWPF-DFOxN5bc.js → gitGraphDiagram-UUTBAWPF-C55GH-OS.js} +1 -1
  50. package/payload/server/public/assets/graph-DUtVdnZ6.js +1 -0
  51. package/payload/server/public/assets/graph-labels-Dxfue-fP.js +1 -0
  52. package/payload/server/public/assets/{graphlib-BBibixaA.js → graphlib-DL9PM7Ex.js} +1 -1
  53. package/payload/server/public/assets/{infoDiagram-42DDH7IO-nH2azhY8.js → infoDiagram-42DDH7IO-BMSGqUbG.js} +1 -1
  54. package/payload/server/public/assets/{ishikawaDiagram-UXIWVN3A-WD3tfqFi.js → ishikawaDiagram-UXIWVN3A-Dw6BZ6BG.js} +1 -1
  55. package/payload/server/public/assets/{journeyDiagram-VCZTEJTY-LUkaVSqw.js → journeyDiagram-VCZTEJTY-DrywUGXw.js} +1 -1
  56. package/payload/server/public/assets/{kanban-definition-6JOO6SKY-Dk-lYgpJ.js → kanban-definition-6JOO6SKY-DuwtVBBc.js} +1 -1
  57. package/payload/server/public/assets/{line-BDv6CEnp.js → line-JAksyKHj.js} +1 -1
  58. package/payload/server/public/assets/{mermaid-parser.core-D2XsSGgp.js → mermaid-parser.core-BMq-ApBW.js} +1 -1
  59. package/payload/server/public/assets/{mermaid.core-FyN-UmQV.js → mermaid.core-tH4oX0Kh.js} +3 -3
  60. package/payload/server/public/assets/{mindmap-definition-QFDTVHPH-BRAHEUIS.js → mindmap-definition-QFDTVHPH-D1OiiJga.js} +1 -1
  61. package/payload/server/public/assets/page-BZpoS7iR.js +1 -0
  62. package/payload/server/public/assets/{page-CZQd-W3C.js → page-CkvBvezS.js} +2 -2
  63. package/payload/server/public/assets/{pieDiagram-DEJITSTG-BqibVC2X.js → pieDiagram-DEJITSTG-Ckwm69PW.js} +1 -1
  64. package/payload/server/public/assets/{public-BDUZIabs.js → public-C-dTMgXu.js} +5 -5
  65. package/payload/server/public/assets/{quadrantDiagram-34T5L4WZ-DNuExGnr.js → quadrantDiagram-34T5L4WZ-COw3yZ1j.js} +1 -1
  66. package/payload/server/public/assets/{requirementDiagram-MS252O5E-5JXTdydh.js → requirementDiagram-MS252O5E-DqGzM4K-.js} +1 -1
  67. package/payload/server/public/assets/{sankeyDiagram-XADWPNL6-B_8rhvcR.js → sankeyDiagram-XADWPNL6-D-l1c_Pl.js} +1 -1
  68. package/payload/server/public/assets/{sequenceDiagram-FGHM5R23-BznkBgjf.js → sequenceDiagram-FGHM5R23-BeIi0DtJ.js} +1 -1
  69. package/payload/server/public/assets/{stateDiagram-FHFEXIEX-BeAZOQfs.js → stateDiagram-FHFEXIEX-C-jgegLk.js} +1 -1
  70. package/payload/server/public/assets/stateDiagram-v2-QKLJ7IA2-BaMs8Znv.js +1 -0
  71. package/payload/server/public/assets/{timeline-definition-GMOUNBTQ-CpJAs-Vw.js → timeline-definition-GMOUNBTQ-BGFKkYmi.js} +1 -1
  72. package/payload/server/public/assets/{vennDiagram-DHZGUBPP-BzH3ItkG.js → vennDiagram-DHZGUBPP-5NuIhJLS.js} +1 -1
  73. package/payload/server/public/assets/{wardleyDiagram-NUSXRM2D-ax9AgwA1.js → wardleyDiagram-NUSXRM2D-Be9ytVut.js} +1 -1
  74. package/payload/server/public/assets/{xychartDiagram-5P7HB3ND-CV6vt_tW.js → xychartDiagram-5P7HB3ND-DCyHg41R.js} +1 -1
  75. package/payload/server/public/data.html +5 -5
  76. package/payload/server/public/graph.html +6 -6
  77. package/payload/server/public/index.html +8 -8
  78. package/payload/server/public/public.html +5 -5
  79. package/payload/server/server.js +135 -85
  80. package/payload/server/public/assets/admin-DgB_IeWB.js +0 -216
  81. package/payload/server/public/assets/channel-BU_eIdRB.js +0 -1
  82. package/payload/server/public/assets/classDiagram-6PBFFD2Q-DMpM1d2b.js +0 -1
  83. package/payload/server/public/assets/classDiagram-v2-HSJHXN6E-D_XbuPVj.js +0 -1
  84. package/payload/server/public/assets/clone-BBT00JUO.js +0 -1
  85. package/payload/server/public/assets/data-BdwO_kv-.js +0 -1
  86. package/payload/server/public/assets/device-url-actions-C8dD0ydz.js +0 -33
  87. package/payload/server/public/assets/graph-CfZJrc9u.js +0 -1
  88. package/payload/server/public/assets/graph-labels-DJ717p00.js +0 -1
  89. package/payload/server/public/assets/page-BWHYktEF.js +0 -1
  90. package/payload/server/public/assets/stateDiagram-v2-QKLJ7IA2-iVlXKz7S.js +0 -1
@@ -0,0 +1,202 @@
1
+ // Task 034 — acceptance grid for samba-provision.ts pure layer.
2
+ //
3
+ // Locks every branch of the rendering, merging, removal, interface-pick, and
4
+ // marker-format functions. No spawnSync, no /etc reads — every test feeds
5
+ // concrete strings or interface fixtures. Mirrors apt-resolve.test.ts in
6
+ // style and runs under `node --test dist/__tests__/*.test.js` after build.
7
+ import test from "node:test";
8
+ import assert from "node:assert/strict";
9
+ import { renderBrandStanza, renderGlobalSection, renderFullSmbConf, pickLanInterface, mergeSmbConf, removeBrandStanza, hasAnyBrandStanza, formatSambaMarker, SAMBA_STEPS, } from "../samba-provision.js";
10
+ // ---------------------------------------------------------------------------
11
+ // Fixtures
12
+ // ---------------------------------------------------------------------------
13
+ const PI_WLAN0_ONLY = {
14
+ lo: [{ address: "127.0.0.1", netmask: "255.0.0.0", family: "IPv4", mac: "00:00:00:00:00:00", internal: true, cidr: "127.0.0.1/8" }],
15
+ wlan0: [{ address: "192.168.1.50", netmask: "255.255.255.0", family: "IPv4", mac: "aa:bb:cc:dd:ee:ff", internal: false, cidr: "192.168.1.50/24" }],
16
+ };
17
+ const PI_BOTH_IFACES = {
18
+ lo: [{ address: "127.0.0.1", netmask: "255.0.0.0", family: "IPv4", mac: "00:00:00:00:00:00", internal: true, cidr: "127.0.0.1/8" }],
19
+ eth0: [{ address: "10.0.0.5", netmask: "255.255.255.0", family: "IPv4", mac: "11:11:11:11:11:11", internal: false, cidr: "10.0.0.5/24" }],
20
+ wlan0: [{ address: "192.168.1.50", netmask: "255.255.255.0", family: "IPv4", mac: "aa:bb:cc:dd:ee:ff", internal: false, cidr: "192.168.1.50/24" }],
21
+ };
22
+ const PI_ETH0_ONLY = {
23
+ lo: [{ address: "127.0.0.1", netmask: "255.0.0.0", family: "IPv4", mac: "00:00:00:00:00:00", internal: true, cidr: "127.0.0.1/8" }],
24
+ eth0: [{ address: "10.0.0.5", netmask: "255.255.255.0", family: "IPv4", mac: "11:11:11:11:11:11", internal: false, cidr: "10.0.0.5/24" }],
25
+ };
26
+ const PI_LO_ONLY = {
27
+ lo: [{ address: "127.0.0.1", netmask: "255.0.0.0", family: "IPv4", mac: "00:00:00:00:00:00", internal: true, cidr: "127.0.0.1/8" }],
28
+ };
29
+ const PI_IPV6_ONLY_LAN = {
30
+ lo: [{ address: "127.0.0.1", netmask: "255.0.0.0", family: "IPv4", mac: "00:00:00:00:00:00", internal: true, cidr: "127.0.0.1/8" }],
31
+ eth0: [{ address: "fe80::1", netmask: "ffff:ffff:ffff:ffff::", family: "IPv6", mac: "11:11:11:11:11:11", internal: false, cidr: "fe80::1/64", scopeid: 0 }],
32
+ };
33
+ const PI_USB0_FALLBACK = {
34
+ lo: [{ address: "127.0.0.1", netmask: "255.0.0.0", family: "IPv4", mac: "00:00:00:00:00:00", internal: true, cidr: "127.0.0.1/8" }],
35
+ usb0: [{ address: "172.17.0.2", netmask: "255.255.255.0", family: "IPv4", mac: "22:22:22:22:22:22", internal: false, cidr: "172.17.0.2/24" }],
36
+ };
37
+ // ---------------------------------------------------------------------------
38
+ // pickLanInterface
39
+ // ---------------------------------------------------------------------------
40
+ test("pickLanInterface: wlan0 wins over eth0 when both present", () => {
41
+ assert.equal(pickLanInterface(PI_BOTH_IFACES), "wlan0");
42
+ });
43
+ test("pickLanInterface: wlan0 alone is picked", () => {
44
+ assert.equal(pickLanInterface(PI_WLAN0_ONLY), "wlan0");
45
+ });
46
+ test("pickLanInterface: eth0 alone is picked", () => {
47
+ assert.equal(pickLanInterface(PI_ETH0_ONLY), "eth0");
48
+ });
49
+ test("pickLanInterface: fallback to any non-loopback IPv4 (usb0)", () => {
50
+ assert.equal(pickLanInterface(PI_USB0_FALLBACK), "usb0");
51
+ });
52
+ test("pickLanInterface: returns null when only loopback is present", () => {
53
+ assert.equal(pickLanInterface(PI_LO_ONLY), null);
54
+ });
55
+ test("pickLanInterface: returns null when the only non-loopback iface has no IPv4", () => {
56
+ // smbd cannot bind to an IPv6-only address with `interfaces = lo eth0` syntax
57
+ // unmodified; treat as no usable LAN interface so caller loud-fails rather
58
+ // than writing a broken smb.conf.
59
+ assert.equal(pickLanInterface(PI_IPV6_ONLY_LAN), null);
60
+ });
61
+ // ---------------------------------------------------------------------------
62
+ // renderBrandStanza — exact spec match
63
+ // ---------------------------------------------------------------------------
64
+ test("renderBrandStanza emits the spec directives in order", () => {
65
+ const stanza = renderBrandStanza({ brand: "maxy-code", sharePath: "/home/admin/maxy-code" });
66
+ assert.equal(stanza, [
67
+ "[maxy-code]",
68
+ " path = /home/admin/maxy-code",
69
+ " read only = no",
70
+ " valid users = admin",
71
+ " force user = admin",
72
+ " browseable = yes",
73
+ " create mask = 0664",
74
+ " directory mask = 0775",
75
+ "",
76
+ ].join("\n"));
77
+ });
78
+ test("renderBrandStanza handles realagent-code with its own share path", () => {
79
+ const stanza = renderBrandStanza({ brand: "realagent-code", sharePath: "/home/admin/realagent-code" });
80
+ assert.match(stanza, /^\[realagent-code\]\n/);
81
+ assert.match(stanza, /\n path = \/home\/admin\/realagent-code\n/);
82
+ assert.match(stanza, /\n force user = admin\n/);
83
+ });
84
+ // ---------------------------------------------------------------------------
85
+ // renderGlobalSection — LAN-only enforcement
86
+ // ---------------------------------------------------------------------------
87
+ test("renderGlobalSection binds to lo + LAN interface only", () => {
88
+ const globals = renderGlobalSection({ lanInterface: "wlan0" });
89
+ assert.match(globals, /^\[global\]\n/);
90
+ assert.match(globals, /\n interfaces = lo wlan0\n/);
91
+ assert.match(globals, /\n bind interfaces only = yes\n/);
92
+ // No `map to guest = ok` — guests are explicitly rejected as bad-user.
93
+ assert.match(globals, /\n map to guest = bad user\n/);
94
+ });
95
+ // ---------------------------------------------------------------------------
96
+ // renderFullSmbConf — composition order
97
+ // ---------------------------------------------------------------------------
98
+ test("renderFullSmbConf places globals before the brand stanza", () => {
99
+ const conf = renderFullSmbConf({ brand: "maxy-code", sharePath: "/home/admin/maxy-code", lanInterface: "wlan0" });
100
+ const globalIdx = conf.indexOf("[global]");
101
+ const brandIdx = conf.indexOf("[maxy-code]");
102
+ assert.ok(globalIdx >= 0 && brandIdx > globalIdx, "[global] must come before [maxy-code]");
103
+ });
104
+ // ---------------------------------------------------------------------------
105
+ // mergeSmbConf — idempotency and section preservation
106
+ // ---------------------------------------------------------------------------
107
+ test("mergeSmbConf: empty existing conf gets globals + brand stanza", () => {
108
+ const merged = mergeSmbConf({ existing: "", brand: "maxy-code", sharePath: "/home/admin/maxy-code", lanInterface: "wlan0" });
109
+ assert.match(merged, /\[global\][\s\S]+\[maxy-code\]/);
110
+ assert.match(merged, /interfaces = lo wlan0/);
111
+ });
112
+ test("mergeSmbConf: idempotent on second application", () => {
113
+ const once = mergeSmbConf({ existing: "", brand: "maxy-code", sharePath: "/home/admin/maxy-code", lanInterface: "wlan0" });
114
+ const twice = mergeSmbConf({ existing: once, brand: "maxy-code", sharePath: "/home/admin/maxy-code", lanInterface: "wlan0" });
115
+ assert.equal(twice, once, "second merge must produce byte-identical output");
116
+ });
117
+ test("mergeSmbConf: replaces an existing global section in place, preserving peer stanzas", () => {
118
+ const existing = [
119
+ "[global]",
120
+ " workgroup = OLDGROUP",
121
+ " interfaces = lo eth0 wlan0",
122
+ "",
123
+ "[printers]",
124
+ " path = /var/spool/samba",
125
+ " guest ok = yes",
126
+ "",
127
+ ].join("\n");
128
+ const merged = mergeSmbConf({ existing, brand: "maxy-code", sharePath: "/home/admin/maxy-code", lanInterface: "wlan0" });
129
+ assert.match(merged, /interfaces = lo wlan0/);
130
+ assert.doesNotMatch(merged, /workgroup = OLDGROUP/);
131
+ assert.match(merged, /\[printers\][\s\S]+path = \/var\/spool\/samba/, "[printers] stanza must survive");
132
+ assert.match(merged, /\[maxy-code\][\s\S]+path = \/home\/admin\/maxy-code/);
133
+ });
134
+ test("mergeSmbConf: replaces an existing brand stanza in place, leaves peer brand alone", () => {
135
+ const peerStanza = renderBrandStanza({ brand: "realagent-code", sharePath: "/home/admin/realagent-code" });
136
+ const oldBrandStanza = "[maxy-code]\n path = /OLD/PATH\n read only = yes\n\n";
137
+ const existing = renderGlobalSection({ lanInterface: "eth0" }) + oldBrandStanza + peerStanza;
138
+ const merged = mergeSmbConf({ existing, brand: "maxy-code", sharePath: "/home/admin/maxy-code", lanInterface: "wlan0" });
139
+ assert.doesNotMatch(merged, /\/OLD\/PATH/, "old maxy-code path must be replaced");
140
+ assert.doesNotMatch(merged, /read only = yes/, "old maxy-code directives must be replaced");
141
+ assert.match(merged, /\[realagent-code\][\s\S]+path = \/home\/admin\/realagent-code/, "peer brand stanza must survive");
142
+ assert.match(merged, /\[maxy-code\][\s\S]+path = \/home\/admin\/maxy-code/);
143
+ });
144
+ test("mergeSmbConf: appends brand stanza when not present, keeps existing peer stanzas", () => {
145
+ const peerStanza = renderBrandStanza({ brand: "realagent-code", sharePath: "/home/admin/realagent-code" });
146
+ const existing = renderGlobalSection({ lanInterface: "eth0" }) + peerStanza;
147
+ const merged = mergeSmbConf({ existing, brand: "maxy-code", sharePath: "/home/admin/maxy-code", lanInterface: "wlan0" });
148
+ assert.match(merged, /\[realagent-code\]/, "peer brand stanza must survive");
149
+ assert.match(merged, /\[maxy-code\][\s\S]+path = \/home\/admin\/maxy-code/, "new brand stanza must be appended");
150
+ });
151
+ // ---------------------------------------------------------------------------
152
+ // removeBrandStanza — peer-brand isolation
153
+ // ---------------------------------------------------------------------------
154
+ test("removeBrandStanza: removes only this brand, leaves peer brand and global intact", () => {
155
+ const conf = mergeSmbConf({
156
+ existing: renderBrandStanza({ brand: "realagent-code", sharePath: "/home/admin/realagent-code" }),
157
+ brand: "maxy-code",
158
+ sharePath: "/home/admin/maxy-code",
159
+ lanInterface: "wlan0",
160
+ });
161
+ const after = removeBrandStanza({ existing: conf, brand: "maxy-code" });
162
+ assert.doesNotMatch(after, /\[maxy-code\]/);
163
+ assert.match(after, /\[realagent-code\]/);
164
+ assert.match(after, /\[global\]/);
165
+ });
166
+ test("removeBrandStanza: returns input unchanged when stanza is absent", () => {
167
+ const conf = mergeSmbConf({ existing: "", brand: "realagent-code", sharePath: "/home/admin/realagent-code", lanInterface: "wlan0" });
168
+ const after = removeBrandStanza({ existing: conf, brand: "maxy-code" });
169
+ assert.equal(after, conf);
170
+ });
171
+ // ---------------------------------------------------------------------------
172
+ // hasAnyBrandStanza — apt-purge gate
173
+ // ---------------------------------------------------------------------------
174
+ test("hasAnyBrandStanza: false when only [global] is present", () => {
175
+ const conf = renderGlobalSection({ lanInterface: "wlan0" });
176
+ assert.equal(hasAnyBrandStanza(conf), false);
177
+ });
178
+ test("hasAnyBrandStanza: true when any non-global stanza is present", () => {
179
+ const conf = renderGlobalSection({ lanInterface: "wlan0" }) +
180
+ renderBrandStanza({ brand: "realagent-code", sharePath: "/home/admin/realagent-code" });
181
+ assert.equal(hasAnyBrandStanza(conf), true);
182
+ });
183
+ test("hasAnyBrandStanza: false when both brand stanzas have been removed", () => {
184
+ let conf = renderFullSmbConf({ brand: "maxy-code", sharePath: "/home/admin/maxy-code", lanInterface: "wlan0" });
185
+ conf = mergeSmbConf({ existing: conf, brand: "realagent-code", sharePath: "/home/admin/realagent-code", lanInterface: "wlan0" });
186
+ conf = removeBrandStanza({ existing: conf, brand: "maxy-code" });
187
+ conf = removeBrandStanza({ existing: conf, brand: "realagent-code" });
188
+ assert.equal(hasAnyBrandStanza(conf), false);
189
+ });
190
+ // ---------------------------------------------------------------------------
191
+ // formatSambaMarker / SAMBA_STEPS — the install-invariant contract
192
+ // ---------------------------------------------------------------------------
193
+ test("SAMBA_STEPS is exactly [apt, conf, user, units] in order", () => {
194
+ // Locked here so a refactor that reorders the steps must update this test
195
+ // deliberately. The task brief pins the four-step contract.
196
+ assert.deepEqual([...SAMBA_STEPS], ["apt", "conf", "user", "units"]);
197
+ });
198
+ test("formatSambaMarker emits the `[install-invariant] samba-provision-<step> <state>` shape", () => {
199
+ assert.equal(formatSambaMarker("apt", "ok"), "[install-invariant] samba-provision-apt ok");
200
+ assert.equal(formatSambaMarker("user", "deferred reason=no-plaintext-pin"), "[install-invariant] samba-provision-user deferred reason=no-plaintext-pin");
201
+ assert.equal(formatSambaMarker("units", "fail: Job for smbd.service failed"), "[install-invariant] samba-provision-units fail: Job for smbd.service failed");
202
+ });
package/dist/index.js CHANGED
@@ -14,6 +14,8 @@ import { parseSwVers, isSupportedMacosVersion } from "./macos-version.js";
14
14
  import { decideChromiumAction, isSnapConfinedPath } from "./snap-chromium.js";
15
15
  import { classifyPortHolder } from "./preflight-port-classifier.js";
16
16
  import { parsePluginList, computeInstallActions, computeConfigureActions, parseExternalPlugins, } from "./lib/plugin-install.js";
17
+ import { pickLanInterface, mergeSmbConf, formatSambaMarker, } from "./samba-provision.js";
18
+ import { networkInterfaces } from "node:os";
17
19
  const PAYLOAD_DIR = resolve(import.meta.dirname, "../payload");
18
20
  // Brand manifest — read from payload to derive all brand-specific installation values.
19
21
  // The bundler stamps brand.json into the payload at build time.
@@ -2424,18 +2426,17 @@ function installTunnelScripts() {
2424
2426
  createTunnelSymlink(listLink, listSrc);
2425
2427
  }
2426
2428
  // ---------------------------------------------------------------------------
2427
- // Account discovery (shared between installService + installCrons)
2429
+ // Account discovery (shared by installService + the post-install summary)
2428
2430
  //
2429
2431
  // Task 955 — `installService` stamps `Environment=ACCOUNT_ID=` into the brand
2430
2432
  // systemd unit so the writeNodeWithEdges gate has a non-undefined identity to
2431
- // compare against; `installCrons` needs the same value to scope cron stdout
2432
- // to the per-account log dir + stamp ACCOUNT_ID into the cron entry env.
2433
- // Both pull from `INSTALL_DIR/data/accounts/<uuid>/account.json` written by
2434
- // seed-neo4j.sh during setupAccount(). One reader, one shape, one source of
2435
- // truth without sharing, the two callers would drift on classification of
2436
- // `corrupt account.json` (one might count it, the other might not) and the
2437
- // gate would reject writes the cron's "scoped" log was already happily
2438
- // publishing under that uuid.
2433
+ // compare against. Pulled from `INSTALL_DIR/data/accounts/<uuid>/account.json`
2434
+ // written by seed-neo4j.sh during setupAccount(). One reader, one shape, one
2435
+ // source of truth.
2436
+ //
2437
+ // Task 039 retired the installer's cron registration `resolveInstallAccountId`
2438
+ // kept its second consumer (installService) and is referenced again at print
2439
+ // time for the post-install banner.
2439
2440
  // ---------------------------------------------------------------------------
2440
2441
  function resolveInstallAccountId() {
2441
2442
  const accountsDir = join(INSTALL_DIR, "data/accounts");
@@ -2450,68 +2451,13 @@ function resolveInstallAccountId() {
2450
2451
  catch { /* directory unreadable */ }
2451
2452
  return "";
2452
2453
  }
2453
- // ---------------------------------------------------------------------------
2454
- // Cron Registration
2455
- //
2456
- // Registers platform cron jobs (heartbeat, email-fetch, email-auto-respond).
2457
- // Uses BEGIN/END markers for idempotent replacement on re-install.
2458
- // Crons persist across reboots registered once at install time.
2459
- // Email crons run unconditionally; scripts exit early if unconfigured.
2460
- // ---------------------------------------------------------------------------
2461
- const CRON_BLOCK_BEGIN = `# BEGIN ${BRAND.productName.toUpperCase()} CRONS`;
2462
- const CRON_BLOCK_END = `# END ${BRAND.productName.toUpperCase()} CRONS`;
2463
- function installCrons() {
2464
- if (!isLinux())
2465
- return;
2466
- const nodeBin = spawnSync("which", ["node"], { encoding: "utf-8" }).stdout.trim() || "/usr/bin/node";
2467
- const platformRoot = join(INSTALL_DIR, "platform");
2468
- // Account discovery shared with installService — see resolveInstallAccountId.
2469
- const accountId = resolveInstallAccountId();
2470
- const accountsDir = join(INSTALL_DIR, "data/accounts");
2471
- if (!accountId) {
2472
- console.error(" Cron jobs: skipped — no account found. Crons will register on the next install after account creation.");
2473
- logFile(" cron registration skipped: no account directory with account.json found");
2474
- return;
2475
- }
2476
- const accountLogDir = join(accountsDir, accountId, "logs");
2477
- // NEO4J_URI is explicit per brand so a secondary-brand install (dedicated
2478
- // Neo4j on a non-default port) doesn't silently fall back to
2479
- // bolt://localhost:7687 in cron — cron inherits none of the user's shell
2480
- // env or .env file. Without this, the scheduler writes and reads from the
2481
- // shared default instance, producing cross-install event leaks (Task 571).
2482
- const accountEnv = `ACCOUNT_ID=${accountId} NEO4J_URI=bolt://localhost:${NEO4J_PORT} `;
2483
- const cronEntries = [
2484
- `* * * * * mkdir -p ${accountLogDir} && PLATFORM_ROOT=${platformRoot} ${accountEnv}${nodeBin} ${platformRoot}/plugins/scheduling/mcp/dist/scripts/check-due-events.js >> ${accountLogDir}/check-due-events.log 2>&1 # heartbeat`,
2485
- `* * * * * mkdir -p ${accountLogDir} && PLATFORM_ROOT=${platformRoot} ${accountEnv}${nodeBin} ${platformRoot}/plugins/email/mcp/dist/scripts/email-fetch.js >> ${accountLogDir}/email-fetch.log 2>&1 # email-fetch`,
2486
- `* * * * * mkdir -p ${accountLogDir} && PLATFORM_ROOT=${platformRoot} ${accountEnv}${nodeBin} ${platformRoot}/plugins/email/mcp/dist/scripts/email-auto-respond.js >> ${accountLogDir}/email-auto-respond.log 2>&1 # email-auto-respond`,
2487
- ];
2488
- // Read existing crontab (empty string if none)
2489
- const existing = spawnSync("crontab", ["-l"], { encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"] });
2490
- const currentCrontab = existing.status === 0 ? existing.stdout : "";
2491
- // Strip any existing Maxy cron block
2492
- const blockPattern = new RegExp(`${CRON_BLOCK_BEGIN}[\\s\\S]*?${CRON_BLOCK_END}\\n?`, "g");
2493
- const cleaned = currentCrontab.replace(blockPattern, "").trimEnd();
2494
- // Build new crontab with Maxy block
2495
- const newBlock = [
2496
- CRON_BLOCK_BEGIN,
2497
- ...cronEntries,
2498
- CRON_BLOCK_END,
2499
- ].join("\n");
2500
- const newCrontab = cleaned ? `${cleaned}\n${newBlock}\n` : `${newBlock}\n`;
2501
- // Write the new crontab
2502
- const write = spawnSync("crontab", ["-"], {
2503
- input: newCrontab,
2504
- encoding: "utf-8",
2505
- stdio: ["pipe", "pipe", "pipe"],
2506
- });
2507
- if (write.status === 0) {
2508
- console.log(" Cron jobs: registered (heartbeat, email-fetch, email-auto-respond)");
2509
- }
2510
- else {
2511
- console.error(` Cron jobs: failed to register — ${(write.stderr || "").trim()}`);
2512
- logFile(` crontab write failed: ${write.stderr}`);
2513
- }
2514
- }
2454
+ // Task 039 — `installCrons()` and the `# BEGIN/END <BRAND> CRONS` block lived
2455
+ // here. All such events must be user-driven, not scheduled by the installer.
2456
+ // The legacy block recreated `data/accounts/<accountId>/logs/` every 60s via
2457
+ // `mkdir -p`, which tripped seed-neo4j.sh's stub-account-dirs guard on every
2458
+ // reinstall after `rm -rf ~/<installDir>`. Uninstall now strips the legacy
2459
+ // block (uninstall.ts `removeLegacyCronBlock`) so older installs migrate
2460
+ // cleanly. Consumer-side dispatcher replacement is tracked separately.
2515
2461
  // Task 664 retired the ttyd/tmux/xterm admin terminal stack. Upgrades run
2516
2462
  // via the action runner — `systemd-run --user` transient units spawned by
2517
2463
  // POST /api/admin/actions/upgrade — whose lifetime is independent of
@@ -3122,8 +3068,6 @@ WantedBy=multi-user.target
3122
3068
  if (!webServerUp) {
3123
3069
  console.log(` Server may still be starting. Check http://${DEVICE_HOSTNAME}.local:${PORT} in a moment.`);
3124
3070
  }
3125
- // Register cron jobs — depends on web server, independent of CDP
3126
- installCrons();
3127
3071
  // Validate CDP: the programmatic Playwright MCP server connects to Chromium
3128
3072
  // via --cdp-endpoint http://127.0.0.1:9222. In virtual (VNC) mode, Chromium is
3129
3073
  // started by vnc.sh (ExecStartPre) before the web server — a failed probe here
@@ -3183,6 +3127,111 @@ WantedBy=multi-user.target
3183
3127
  }
3184
3128
  }
3185
3129
  // ---------------------------------------------------------------------------
3130
+ // Task 034 — Samba provisioning. Runs after installService() so SMB never
3131
+ // blocks the admin server starting; pure decisions live in samba-provision.ts.
3132
+ //
3133
+ // Four steps, each emitting one [install-invariant] samba-provision-<step>
3134
+ // marker:
3135
+ // apt — install the `samba` package
3136
+ // conf — write the brand stanza + LAN-only globals to /etc/samba/smb.conf
3137
+ // user — smbpasswd the admin Linux user; deferred at install time on fresh
3138
+ // Pi installs (no plaintext PIN until the operator runs set-pin)
3139
+ // units — systemctl enable --now smbd nmbd
3140
+ //
3141
+ // The platform's set-pin route handler closes the deferral loop by running
3142
+ // `smbpasswd -a admin` inline whenever the PIN is set or rotated.
3143
+ // ---------------------------------------------------------------------------
3144
+ function emitSambaMarker(step, state) {
3145
+ const line = formatSambaMarker(step, state);
3146
+ console.log(` ${line}`);
3147
+ logFile(` ${line}`);
3148
+ }
3149
+ function provisionSamba() {
3150
+ if (!isLinux()) {
3151
+ logFile(` samba-provision skipped: platform=${process.platform}`);
3152
+ return;
3153
+ }
3154
+ const brand = BRAND.hostname;
3155
+ const sharePath = INSTALL_DIR;
3156
+ // Step 1 — apt install samba (reuses installAptGroup so resolution, post-
3157
+ // check, and apt diagnostics are identical to every other system dep).
3158
+ try {
3159
+ installAptGroup("samba", ["samba"]);
3160
+ emitSambaMarker("apt", "ok");
3161
+ }
3162
+ catch (err) {
3163
+ const stderr = err instanceof Error ? err.message : String(err);
3164
+ emitSambaMarker("apt", `fail: ${stderr}`);
3165
+ throw err;
3166
+ }
3167
+ // Step 2 — write the brand stanza into /etc/samba/smb.conf.
3168
+ const lanInterface = pickLanInterface(networkInterfaces());
3169
+ if (!lanInterface) {
3170
+ emitSambaMarker("conf", "fail: no non-loopback IPv4 interface");
3171
+ throw new Error("samba-provision: no LAN interface with IPv4 — cannot bind smbd safely");
3172
+ }
3173
+ const SMB_CONF = "/etc/samba/smb.conf";
3174
+ let existing = "";
3175
+ if (existsSync(SMB_CONF)) {
3176
+ try {
3177
+ const cat = spawnSync("sudo", ["cat", SMB_CONF], { encoding: "utf-8", stdio: "pipe", timeout: 5_000 });
3178
+ if (cat.status === 0)
3179
+ existing = cat.stdout ?? "";
3180
+ }
3181
+ catch { /* treat as empty */ }
3182
+ }
3183
+ const merged = mergeSmbConf({ existing, brand, sharePath, lanInterface });
3184
+ try {
3185
+ const tee = spawnSync("sudo", ["tee", SMB_CONF], { input: merged, encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"], timeout: 10_000 });
3186
+ if (tee.status !== 0) {
3187
+ throw new Error(`sudo tee ${SMB_CONF} exited ${tee.status}: ${(tee.stderr ?? "").trim()}`);
3188
+ }
3189
+ const testparm = spawnSync("sudo", ["testparm", "-s", "--suppress-prompt"], { stdio: "pipe", encoding: "utf-8", timeout: 10_000 });
3190
+ if (testparm.status !== 0) {
3191
+ throw new Error(`testparm rejected merged smb.conf: ${(testparm.stderr ?? "").trim()}`);
3192
+ }
3193
+ // Grant passwordless sudo to the admin user for the two smbpasswd
3194
+ // invocations the platform's set-pin route makes (add-user-with-stdin
3195
+ // and remove-user). Without this, the user systemd-unit-spawned admin
3196
+ // server cannot sync the SMB password when the operator sets/rotates
3197
+ // the PIN. Scoped tight: only these two literal arg-vectors are
3198
+ // permitted; every other smbpasswd flavour still prompts.
3199
+ const SUDOERS = "/etc/sudoers.d/maxy-samba";
3200
+ const sudoersBody = "# Task 034 — maxy-code Samba provisioning (smbpasswd sync from platform set-pin route).\n" +
3201
+ "admin ALL=(root) NOPASSWD: /usr/bin/smbpasswd -a -s admin, /usr/bin/smbpasswd -x admin\n";
3202
+ const sudoersTee = spawnSync("sudo", ["tee", SUDOERS], { input: sudoersBody, encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"], timeout: 5_000 });
3203
+ if (sudoersTee.status !== 0) {
3204
+ throw new Error(`sudo tee ${SUDOERS} exited ${sudoersTee.status}: ${(sudoersTee.stderr ?? "").trim()}`);
3205
+ }
3206
+ spawnSync("sudo", ["chmod", "0440", SUDOERS], { stdio: "pipe", timeout: 5_000 });
3207
+ const visudoCheck = spawnSync("sudo", ["visudo", "-c", "-f", SUDOERS], { stdio: "pipe", encoding: "utf-8", timeout: 5_000 });
3208
+ if (visudoCheck.status !== 0) {
3209
+ throw new Error(`visudo rejected ${SUDOERS}: ${(visudoCheck.stderr ?? "").trim()}`);
3210
+ }
3211
+ emitSambaMarker("conf", `ok lan=${lanInterface}`);
3212
+ }
3213
+ catch (err) {
3214
+ const stderr = err instanceof Error ? err.message : String(err);
3215
+ emitSambaMarker("conf", `fail: ${stderr}`);
3216
+ throw err;
3217
+ }
3218
+ // Step 3 — smbpasswd for the admin user. At install time the plaintext PIN
3219
+ // is not in any file on disk (users.json stores the SHA-256 hash only); the
3220
+ // platform's set-pin route is responsible for syncing on PIN set/rotate.
3221
+ // Mark as deferred so the four-marker contract still fires unbroken.
3222
+ emitSambaMarker("user", "deferred reason=no-plaintext-pin-at-install (set-pin route syncs)");
3223
+ // Step 4 — enable + start smbd and nmbd.
3224
+ try {
3225
+ shell("systemctl", ["enable", "--now", "smbd", "nmbd"], { sudo: true, timeout: 30_000 });
3226
+ emitSambaMarker("units", "ok");
3227
+ }
3228
+ catch (err) {
3229
+ const stderr = err instanceof Error ? err.message : String(err);
3230
+ emitSambaMarker("units", `fail: ${stderr}`);
3231
+ throw err;
3232
+ }
3233
+ }
3234
+ // ---------------------------------------------------------------------------
3186
3235
  // Main
3187
3236
  // ---------------------------------------------------------------------------
3188
3237
  // Route to uninstall if --uninstall flag is present
@@ -3516,6 +3565,11 @@ try {
3516
3565
  setupAccount();
3517
3566
  installTunnelScripts(); // ~/setup-tunnel.sh, ~/reset-tunnel.sh — the SKILL contract
3518
3567
  installService();
3568
+ // Task 034 — Samba on the LAN. Runs after the brand systemd unit is up so
3569
+ // SMB provisioning never blocks the admin server starting; loud-fail per
3570
+ // step. The smbpasswd `user` step is deferred at install time and synced
3571
+ // by the platform's /set-pin route when the operator sets a PIN.
3572
+ provisionSamba();
3519
3573
  console.log("");
3520
3574
  console.log("================================================================");
3521
3575
  console.log("");