@rubytech/create-maxy-code 0.1.22 → 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.
- package/dist/__tests__/samba-provision.test.js +202 -0
- package/dist/index.js +127 -73
- package/dist/samba-provision.js +215 -0
- package/dist/uninstall.js +160 -3
- package/package.json +1 -1
- package/payload/platform/plugins/docs/references/deployment.md +20 -0
- package/payload/platform/plugins/email/references/email-reference.md +4 -4
- package/payload/platform/plugins/scheduling/PLUGIN.md +1 -1
- package/payload/platform/plugins/workflows/PLUGIN.md +2 -2
- package/payload/platform/plugins/workflows/skills/workflow-manager/SKILL.md +1 -1
- package/payload/platform/templates/agents/admin/IDENTITY.md +12 -18
- package/payload/platform/templates/specialists/agents/personal-assistant.md +1 -1
- package/payload/platform/templates/specialists/agents/project-manager.md +1 -1
- package/payload/server/public/assets/{Checkbox-B79fVxpA.js → Checkbox-D1OQD43b.js} +1 -1
- package/payload/server/public/assets/admin-czNBxWor.js +216 -0
- package/payload/server/public/assets/{architectureDiagram-Q4EWVU46-D8e59YJ0.js → architectureDiagram-Q4EWVU46-BcwgT80u.js} +1 -1
- package/payload/server/public/assets/{blockDiagram-DXYQGD6D-CxaDkc0A.js → blockDiagram-DXYQGD6D-BMSyZUQA.js} +1 -1
- package/payload/server/public/assets/{brand-Cg9t5U6J.css → brand-2cku8WFs.css} +1 -1
- package/payload/server/public/assets/{brand-jT16ErmC.js → brand-CSQuxS9w.js} +1 -1
- package/payload/server/public/assets/{c4Diagram-AHTNJAMY-D0PAvq-q.js → c4Diagram-AHTNJAMY-DPRGY1jJ.js} +1 -1
- package/payload/server/public/assets/channel-fxEghWew.js +1 -0
- package/payload/server/public/assets/{chunk-336JU56O-B-CXn-Et.js → chunk-336JU56O-B7oQ3g1c.js} +2 -2
- package/payload/server/public/assets/{chunk-426QAEUC-BLzCQHKA.js → chunk-426QAEUC-C1P0yFXw.js} +1 -1
- package/payload/server/public/assets/{chunk-4TB4RGXK-Bql1UwLT.js → chunk-4TB4RGXK-LI7kOJd0.js} +1 -1
- package/payload/server/public/assets/{chunk-5FUZZQ4R-CQK7jBtX.js → chunk-5FUZZQ4R-CXQRGTQE.js} +1 -1
- package/payload/server/public/assets/{chunk-5PVQY5BW-AJc1-lvX.js → chunk-5PVQY5BW-NSyzpXRy.js} +1 -1
- package/payload/server/public/assets/{chunk-EDXVE4YY-Cf3E3THL.js → chunk-EDXVE4YY-voNwxbDs.js} +1 -1
- package/payload/server/public/assets/{chunk-ENJZ2VHE-BNx6z6hJ.js → chunk-ENJZ2VHE-CMEMPzYY.js} +1 -1
- package/payload/server/public/assets/{chunk-ICPOFSXX-DBUEFs2-.js → chunk-ICPOFSXX-hEbwu-pe.js} +1 -1
- package/payload/server/public/assets/{chunk-OYMX7WX6-Csx2p315.js → chunk-OYMX7WX6-DxskDrLs.js} +1 -1
- package/payload/server/public/assets/{chunk-U2HBQHQK-x17h7UYW.js → chunk-U2HBQHQK-D7TKgUo0.js} +1 -1
- package/payload/server/public/assets/{chunk-X2U36JSP--Lkl5yjV.js → chunk-X2U36JSP-BvPUQEPm.js} +1 -1
- package/payload/server/public/assets/{chunk-YZCP3GAM-C4GsNX8A.js → chunk-YZCP3GAM-BY-RWQUW.js} +1 -1
- package/payload/server/public/assets/{chunk-ZZ45TVLE-YrhUPmZc.js → chunk-ZZ45TVLE-DZvOYDY6.js} +1 -1
- package/payload/server/public/assets/classDiagram-6PBFFD2Q-BsWzGW0N.js +1 -0
- package/payload/server/public/assets/classDiagram-v2-HSJHXN6E-BGVa3h90.js +1 -0
- package/payload/server/public/assets/clone-Khvocke2.js +1 -0
- package/payload/server/public/assets/{dagre-YVALPG-M.js → dagre-Bt-fpckL.js} +1 -1
- package/payload/server/public/assets/{dagre-KV5264BT-D6JU6DW_.js → dagre-KV5264BT-Cnj0mUZl.js} +1 -1
- package/payload/server/public/assets/data-DBd-Buhp.js +1 -0
- package/payload/server/public/assets/device-url-actions-Bjz3Xzbm.js +33 -0
- package/payload/server/public/assets/{diagram-5BDNPKRD-yeO06N5Q.js → diagram-5BDNPKRD-DjLzvOlx.js} +1 -1
- package/payload/server/public/assets/{diagram-G4DWMVQ6-DzbVT_BC.js → diagram-G4DWMVQ6-DTfuRd-T.js} +1 -1
- package/payload/server/public/assets/{diagram-MMDJMWI5-DwYO5VZF.js → diagram-MMDJMWI5-BaL2mCnx.js} +1 -1
- package/payload/server/public/assets/{diagram-TYMM5635-BLUcdkDS.js → diagram-TYMM5635-C5InWY5R.js} +1 -1
- package/payload/server/public/assets/{erDiagram-SMLLAGMA-BiEUB19e.js → erDiagram-SMLLAGMA-DO7BXTpn.js} +1 -1
- package/payload/server/public/assets/{flowDiagram-DWJPFMVM-TILIKxOp.js → flowDiagram-DWJPFMVM-DDdAKfLf.js} +1 -1
- package/payload/server/public/assets/{ganttDiagram-T4ZO3ILL-B7cGzYqT.js → ganttDiagram-T4ZO3ILL-arJD8Utm.js} +1 -1
- package/payload/server/public/assets/{gitGraphDiagram-UUTBAWPF-DFOxN5bc.js → gitGraphDiagram-UUTBAWPF-C55GH-OS.js} +1 -1
- package/payload/server/public/assets/graph-DUtVdnZ6.js +1 -0
- package/payload/server/public/assets/graph-labels-Dxfue-fP.js +1 -0
- package/payload/server/public/assets/{graphlib-BBibixaA.js → graphlib-DL9PM7Ex.js} +1 -1
- package/payload/server/public/assets/{infoDiagram-42DDH7IO-nH2azhY8.js → infoDiagram-42DDH7IO-BMSGqUbG.js} +1 -1
- package/payload/server/public/assets/{ishikawaDiagram-UXIWVN3A-WD3tfqFi.js → ishikawaDiagram-UXIWVN3A-Dw6BZ6BG.js} +1 -1
- package/payload/server/public/assets/{journeyDiagram-VCZTEJTY-LUkaVSqw.js → journeyDiagram-VCZTEJTY-DrywUGXw.js} +1 -1
- package/payload/server/public/assets/{kanban-definition-6JOO6SKY-Dk-lYgpJ.js → kanban-definition-6JOO6SKY-DuwtVBBc.js} +1 -1
- package/payload/server/public/assets/{line-BDv6CEnp.js → line-JAksyKHj.js} +1 -1
- package/payload/server/public/assets/{mermaid-parser.core-D2XsSGgp.js → mermaid-parser.core-BMq-ApBW.js} +1 -1
- package/payload/server/public/assets/{mermaid.core-FyN-UmQV.js → mermaid.core-tH4oX0Kh.js} +3 -3
- package/payload/server/public/assets/{mindmap-definition-QFDTVHPH-BRAHEUIS.js → mindmap-definition-QFDTVHPH-D1OiiJga.js} +1 -1
- package/payload/server/public/assets/page-BZpoS7iR.js +1 -0
- package/payload/server/public/assets/{page-CTbSJbem.js → page-CkvBvezS.js} +2 -2
- package/payload/server/public/assets/{pieDiagram-DEJITSTG-BqibVC2X.js → pieDiagram-DEJITSTG-Ckwm69PW.js} +1 -1
- package/payload/server/public/assets/{public-BDUZIabs.js → public-C-dTMgXu.js} +5 -5
- package/payload/server/public/assets/{quadrantDiagram-34T5L4WZ-DNuExGnr.js → quadrantDiagram-34T5L4WZ-COw3yZ1j.js} +1 -1
- package/payload/server/public/assets/{requirementDiagram-MS252O5E-5JXTdydh.js → requirementDiagram-MS252O5E-DqGzM4K-.js} +1 -1
- package/payload/server/public/assets/{sankeyDiagram-XADWPNL6-B_8rhvcR.js → sankeyDiagram-XADWPNL6-D-l1c_Pl.js} +1 -1
- package/payload/server/public/assets/{sequenceDiagram-FGHM5R23-BznkBgjf.js → sequenceDiagram-FGHM5R23-BeIi0DtJ.js} +1 -1
- package/payload/server/public/assets/{stateDiagram-FHFEXIEX-BeAZOQfs.js → stateDiagram-FHFEXIEX-C-jgegLk.js} +1 -1
- package/payload/server/public/assets/stateDiagram-v2-QKLJ7IA2-BaMs8Znv.js +1 -0
- package/payload/server/public/assets/{timeline-definition-GMOUNBTQ-CpJAs-Vw.js → timeline-definition-GMOUNBTQ-BGFKkYmi.js} +1 -1
- package/payload/server/public/assets/{vennDiagram-DHZGUBPP-BzH3ItkG.js → vennDiagram-DHZGUBPP-5NuIhJLS.js} +1 -1
- package/payload/server/public/assets/{wardleyDiagram-NUSXRM2D-ax9AgwA1.js → wardleyDiagram-NUSXRM2D-Be9ytVut.js} +1 -1
- package/payload/server/public/assets/{xychartDiagram-5P7HB3ND-CV6vt_tW.js → xychartDiagram-5P7HB3ND-DCyHg41R.js} +1 -1
- package/payload/server/public/data.html +5 -5
- package/payload/server/public/graph.html +6 -6
- package/payload/server/public/index.html +8 -8
- package/payload/server/public/public.html +5 -5
- package/payload/server/server.js +135 -85
- package/payload/server/public/assets/admin-CXLuiXFU.js +0 -216
- package/payload/server/public/assets/channel-BU_eIdRB.js +0 -1
- package/payload/server/public/assets/classDiagram-6PBFFD2Q-DMpM1d2b.js +0 -1
- package/payload/server/public/assets/classDiagram-v2-HSJHXN6E-D_XbuPVj.js +0 -1
- package/payload/server/public/assets/clone-BBT00JUO.js +0 -1
- package/payload/server/public/assets/data-BdwO_kv-.js +0 -1
- package/payload/server/public/assets/device-url-actions-C8dD0ydz.js +0 -33
- package/payload/server/public/assets/graph-DpgsOhUZ.js +0 -1
- package/payload/server/public/assets/graph-labels-DJ717p00.js +0 -1
- package/payload/server/public/assets/page-BWHYktEF.js +0 -1
- 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
|
|
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
|
|
2432
|
-
//
|
|
2433
|
-
//
|
|
2434
|
-
//
|
|
2435
|
-
//
|
|
2436
|
-
//
|
|
2437
|
-
//
|
|
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
|
-
//
|
|
2455
|
-
//
|
|
2456
|
-
//
|
|
2457
|
-
//
|
|
2458
|
-
//
|
|
2459
|
-
//
|
|
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("");
|