@lawaty/lacode 0.1.0 → 0.1.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.
- package/README.md +165 -18
- package/dist/lacode.js +43 -12
- package/dist/tunnel.js +42 -12
- package/docs/TUNNEL.md +39 -6
- package/docs/USER-STORIES.md +25 -0
- package/package.json +4 -2
package/README.md
CHANGED
|
@@ -39,6 +39,12 @@ directory to create. Do not run `npm i -g @lawaty/lacode`: a global npm install
|
|
|
39
39
|
is never consulted by opencode, so it changes nothing about whether the plugin
|
|
40
40
|
loads.
|
|
41
41
|
|
|
42
|
+
It is also the form to keep as your default: if your opencode does not load the
|
|
43
|
+
bare specifier, add to this line rather than replacing it. The
|
|
44
|
+
[procedure below](#if-the-agents-are-missing) says when that is, and
|
|
45
|
+
[which versions this is known on](#which-opencode-versions-this-is-known-on)
|
|
46
|
+
says what is actually verified.
|
|
47
|
+
|
|
42
48
|
To install it explicitly instead — or to install it once for every project on
|
|
43
49
|
the machine rather than per config — use the CLI, which does the same thing:
|
|
44
50
|
|
|
@@ -53,25 +59,53 @@ agent set will look stale for no visible reason.
|
|
|
53
59
|
|
|
54
60
|
### Where it is installed
|
|
55
61
|
|
|
56
|
-
opencode installs npm plugins itself, into one directory per specifier under
|
|
57
|
-
|
|
58
|
-
|
|
62
|
+
opencode installs npm plugins itself, into one directory per specifier under its
|
|
63
|
+
cache — not into your project's `node_modules`, and not from anywhere a global
|
|
64
|
+
npm install put them. Ask opencode where that cache is instead of assuming,
|
|
65
|
+
because where it lives differs by platform:
|
|
66
|
+
|
|
67
|
+
```bash
|
|
68
|
+
opencode debug paths
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
It prints the directories opencode is using on this machine — config, data, log
|
|
72
|
+
and the rest. Find the **cache** line and append
|
|
73
|
+
`/packages/<specifier>/node_modules/` to the path it reports. On Linux with no
|
|
74
|
+
`XDG_CACHE_HOME` set, that comes out as, for example:
|
|
59
75
|
|
|
60
76
|
```
|
|
61
|
-
|
|
62
|
-
# ~/.cache/opencode/packages/@lawaty/lacode@latest/node_modules/ by default
|
|
77
|
+
~/.cache/opencode/packages/@lawaty/lacode@latest/node_modules/
|
|
63
78
|
```
|
|
64
79
|
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
80
|
+
**That last line is an illustration of one platform's layout, not a path to
|
|
81
|
+
copy.** Where the same cache root sits on another operating system is not
|
|
82
|
+
something this document should guess at — read it from `opencode debug paths`
|
|
83
|
+
and use what it prints.
|
|
84
|
+
|
|
85
|
+
To inspect what a specifier actually resolved to, list its directory under that
|
|
86
|
+
cache root. An **empty** directory with no `node_modules` inside it means the
|
|
87
|
+
install failed — almost always an unpublished or misspelled package — and
|
|
88
|
+
opencode starts normally **without** the plugin. A **populated** directory there
|
|
89
|
+
means the install succeeded and tells you nothing at all about whether the
|
|
90
|
+
plugin was loaded; that is the next section, and telling those two apart is the
|
|
91
|
+
whole reason for reading it.
|
|
92
|
+
|
|
93
|
+
### If the agents are missing
|
|
94
|
+
|
|
95
|
+
A plugin that fails to load is silent: opencode starts normally, prints nothing,
|
|
96
|
+
logs nothing, and the agents are simply not there. Work down this. Every step
|
|
97
|
+
below is written to hold whichever opencode version you are running — none of
|
|
98
|
+
them depends on knowing the answer in advance.
|
|
99
|
+
|
|
100
|
+
**1 — Write the bare specifier and restart.**
|
|
101
|
+
|
|
102
|
+
```json
|
|
103
|
+
{ "plugin": ["@lawaty/lacode"] }
|
|
104
|
+
```
|
|
70
105
|
|
|
71
|
-
|
|
106
|
+
Nothing here hot-reloads, so the restart is part of the step, not a footnote.
|
|
72
107
|
|
|
73
|
-
|
|
74
|
-
and it simply does not have the agents. Check for them:
|
|
108
|
+
**2 — Check whether the plugin loaded.**
|
|
75
109
|
|
|
76
110
|
```bash
|
|
77
111
|
opencode agent list | grep -E '^(explore-fast|implement-fast|explore-deep|implement-deep)'
|
|
@@ -79,11 +113,123 @@ opencode agent list | grep -E '^(explore-fast|implement-fast|explore-deep|implem
|
|
|
79
113
|
|
|
80
114
|
A working install prints `explore-fast`, `implement-fast`, `explore-deep` and
|
|
81
115
|
`implement-deep`, each with the hidden `-1`..`-4` pool variants alongside. If the
|
|
82
|
-
grep returns nothing
|
|
83
|
-
|
|
84
|
-
actually reads. Run this in the same shell environment opencode runs in — an
|
|
116
|
+
grep returns nothing and the list holds only opencode's own built-ins, the plugin
|
|
117
|
+
did not load. Run it in the same shell environment opencode runs in — an
|
|
85
118
|
`agent list` under a different `HOME` or `XDG_CONFIG_HOME` tests a different
|
|
86
|
-
machine.
|
|
119
|
+
machine. **If the agents are listed, stop here: the one line above is the whole
|
|
120
|
+
install**, and nothing further in this section applies to you.
|
|
121
|
+
|
|
122
|
+
**3 — Decide which failure you are looking at.** Inspect the package directory
|
|
123
|
+
under the cache root from
|
|
124
|
+
[Where it is installed](#where-it-is-installed), and also check that `plugin`
|
|
125
|
+
really is in the config file opencode reads.
|
|
126
|
+
|
|
127
|
+
- **Empty** (no `node_modules` inside): the install failed — check the package
|
|
128
|
+
name, and that it is published. There is nothing to load yet.
|
|
129
|
+
- **Populated**: the install worked and the loader dropped the plugin. Go to
|
|
130
|
+
step 4. These two look identical from the outside, which is why this step
|
|
131
|
+
exists.
|
|
132
|
+
|
|
133
|
+
**4 — If the install worked and the agents are still missing, point the loader
|
|
134
|
+
at the built file.** Add a second entry; leave the bare name in place:
|
|
135
|
+
|
|
136
|
+
```json
|
|
137
|
+
{
|
|
138
|
+
"plugin": [
|
|
139
|
+
"@lawaty/lacode",
|
|
140
|
+
"file:///ABSOLUTE-PATH-FROM-debug-paths/packages/@lawaty/lacode@latest/node_modules/@lawaty/lacode/dist/index.js"
|
|
141
|
+
]
|
|
142
|
+
}
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
`ABSOLUTE-PATH-FROM-debug-paths` is a placeholder to be replaced, not a
|
|
146
|
+
directory: take the cache path `opencode debug paths` reports for **your**
|
|
147
|
+
machine and put it in front of `/packages/…`. It must be an absolute path with
|
|
148
|
+
`$HOME` already expanded and no `~` left in it. Re-derive it after a cache wipe
|
|
149
|
+
or a reinstall — the directory is keyed by specifier, so it moves when the
|
|
150
|
+
version does.
|
|
151
|
+
|
|
152
|
+
Why keep both entries instead of swapping the bare name out:
|
|
153
|
+
|
|
154
|
+
- The bare name is what makes opencode install the package into its cache, and
|
|
155
|
+
therefore what creates the path the `file://` entry points at.
|
|
156
|
+
- The `file://` entry is what actually loads the plugin.
|
|
157
|
+
- They **cannot double-inject** on opencode 1.18.35, where the bare name
|
|
158
|
+
contributes nothing at all: the pair loads the plugin exactly once. On any
|
|
159
|
+
other version this is untested, which is precisely why step 4 is something you
|
|
160
|
+
take *because* step 2 showed the agents missing, and never on the expectation
|
|
161
|
+
that it would help.
|
|
162
|
+
|
|
163
|
+
**5 — Restart, and expect two restarts after a cache wipe.** The first start
|
|
164
|
+
installs the package and creates the path the `file://` entry points at, but a
|
|
165
|
+
path that does not exist at startup is a path that cannot be loaded in that same
|
|
166
|
+
run. The second start finds it and loads the plugin. After that it is steady
|
|
167
|
+
state.
|
|
168
|
+
|
|
169
|
+
This package also publishes `main` and an `exports["./server"]` entry pointing at
|
|
170
|
+
the same built file, because those are two of the shapes a plugin loader
|
|
171
|
+
resolving an entry point consults. They are forward-compatibility metadata and
|
|
172
|
+
they cost nothing — but they do **not** fix this on 1.18.35: adding either one to
|
|
173
|
+
the *installed* cache copy was tested there and the bare specifier still loaded
|
|
174
|
+
nothing. No other version has been observed where they help either. Use the
|
|
175
|
+
`file://` entry.
|
|
176
|
+
|
|
177
|
+
### Which opencode versions this is known on
|
|
178
|
+
|
|
179
|
+
Exactly one version has been measured, and the procedure above is deliberately
|
|
180
|
+
not built around that fact. Treat this table as the evidence it is, which is one
|
|
181
|
+
row wide.
|
|
182
|
+
|
|
183
|
+
| opencode version | bare spec loads? | `file://` needed? | evidence |
|
|
184
|
+
| --- | --- | --- | --- |
|
|
185
|
+
| 1.18.35 | No — installs the package, then silently drops it | Yes | **Verified** on 1.18.35: a populated cache directory, zero agents injected, no error and no log line; the `file://` entry loads the full agent set. |
|
|
186
|
+
| Any other version | **Unknown** | **Unknown** | **Untested.** No other opencode version has been run against this package in either direction. |
|
|
187
|
+
|
|
188
|
+
Every command quoted on this page was run against 1.18.35. **If your version is
|
|
189
|
+
not the one measured here, follow the procedure above rather than reading this
|
|
190
|
+
table as coverage of your build**: each step stands on its own, and step 2 tells
|
|
191
|
+
you which situation you are in before any version-specific advice is relevant.
|
|
192
|
+
|
|
193
|
+
### macOS
|
|
194
|
+
|
|
195
|
+
The plugin runs on macOS. It is plain JavaScript with no native dependency, and
|
|
196
|
+
what the package publishes is the built JavaScript rather than TypeScript
|
|
197
|
+
sources, so nothing about loading it is Linux-only. Use the procedure above
|
|
198
|
+
unchanged.
|
|
199
|
+
|
|
200
|
+
Three things differ, and all three are worth reading rather than assuming:
|
|
201
|
+
|
|
202
|
+
- **Paths.** Do not copy a cache path out of this document — the example under
|
|
203
|
+
[Where it is installed](#where-it-is-installed) is one platform's layout. Run
|
|
204
|
+
`opencode debug paths` and build the `file://` entry from what it reports.
|
|
205
|
+
This page deliberately does not name a macOS cache root: where opencode keeps
|
|
206
|
+
it on a Mac has not been established here, and a plausible-looking guess is
|
|
207
|
+
worse than an instruction to look.
|
|
208
|
+
- **Notifications** are delivered through `osascript`, or through
|
|
209
|
+
`terminal-notifier` when `osascript` is not installed, rather than through the
|
|
210
|
+
Linux `notify-send` (US-26). `lacode doctor` reports which channel your machine
|
|
211
|
+
would actually use.
|
|
212
|
+
- **The repository's shell scripts are Linux-only by design**, and are not in the
|
|
213
|
+
published package. They drive one person's SSH topology, so each is on the side
|
|
214
|
+
it belongs to:
|
|
215
|
+
|
|
216
|
+
| Script | Runs on | Why it is Linux-only |
|
|
217
|
+
| --- | --- | --- |
|
|
218
|
+
| `bin/oc`, `bin/oc-notify` | the **remote host** that runs opencode | Server-side. They are pushed to `~/bin/` on each host by `bin/oc-sync` and notify *your desktop* over the tunnel. |
|
|
219
|
+
| `bin/oc-notify-receiver` | your **desktop** | Delivers through `notify-send` (libnotify), and the persistent forward that reaches it is a systemd user unit. |
|
|
220
|
+
| `bin/oc-sync` | your **desktop** | Installs those systemd user units and runs `loginctl enable-linger`. |
|
|
221
|
+
|
|
222
|
+
None of this is a limitation of the package: a macOS client installs the plugin
|
|
223
|
+
and uses the `lacode` CLI, and both are portable. The scripts above are the
|
|
224
|
+
author's personal remote-tunnel tooling. `bin/oc-sync` refuses to run where it
|
|
225
|
+
cannot work and says which `lacode` subcommand to use instead, rather than
|
|
226
|
+
failing somewhere less obvious.
|
|
227
|
+
|
|
228
|
+
**The test suite runs on macOS.** It is written to pass on a Mac as well as on
|
|
229
|
+
Linux, so "it works on a Mac" is a supported claim rather than an intention. The
|
|
230
|
+
few checks that exercise the Linux-only shell scripts above skip themselves on a
|
|
231
|
+
machine that cannot run them, naming the reason — a bash 3.2 with no `mapfile`,
|
|
232
|
+
or no GNU `timeout` — rather than passing silently or failing confusingly.
|
|
87
233
|
|
|
88
234
|
### The `lacode` CLI
|
|
89
235
|
|
|
@@ -407,7 +553,8 @@ they call the same `scripts/build.mjs` the tarball does.
|
|
|
407
553
|
is the guard: it reads the `### US-N:` headings out of `docs/USER-STORIES.md`
|
|
408
554
|
and the `[US-N]` markers out of the test names and fails if they disagree, in
|
|
409
555
|
either direction. **A test that covers a story must have the id at the start of
|
|
410
|
-
its name** — `test("[US-7] a paid model is accepted")` — or
|
|
556
|
+
its name** — `test("[US-7] a paid model is accepted")` — or the coverage guard
|
|
557
|
+
fails it.
|
|
411
558
|
A story with no test is as much a failure as a test naming a story that does not
|
|
412
559
|
exist.
|
|
413
560
|
|
package/dist/lacode.js
CHANGED
|
@@ -81,15 +81,41 @@ var have = (command2) => {
|
|
|
81
81
|
return false;
|
|
82
82
|
}
|
|
83
83
|
};
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
84
|
+
var CHANNEL_BINARY = {
|
|
85
|
+
"notify-send": "notify-send",
|
|
86
|
+
osascript: "osascript",
|
|
87
|
+
"terminal-notifier": "terminal-notifier"
|
|
88
|
+
};
|
|
89
|
+
var DARWIN_SCRIPT = [
|
|
90
|
+
"on run argv",
|
|
91
|
+
" display notification (item 3 of argv) with title (item 2 of argv)",
|
|
92
|
+
"end run"
|
|
93
|
+
].join("\n");
|
|
94
|
+
var DARWIN_ARGV_MARKER = "lacode";
|
|
95
|
+
function localChannel(platform = process.platform) {
|
|
96
|
+
if (platform === "darwin") {
|
|
97
|
+
if (have(CHANNEL_BINARY.osascript)) return "osascript";
|
|
98
|
+
if (have(CHANNEL_BINARY["terminal-notifier"])) return "terminal-notifier";
|
|
99
|
+
return "none";
|
|
100
|
+
}
|
|
101
|
+
return have(CHANNEL_BINARY["notify-send"]) ? "notify-send" : "none";
|
|
102
|
+
}
|
|
103
|
+
var NO_CHANNEL = {
|
|
104
|
+
linux: "notify-send is not installed",
|
|
105
|
+
darwin: "neither osascript nor terminal-notifier is installed"
|
|
106
|
+
};
|
|
107
|
+
function sendLocal(request, platform = process.platform) {
|
|
108
|
+
const channel = localChannel(platform);
|
|
109
|
+
if (channel === "none") {
|
|
110
|
+
return { ok: false, detail: NO_CHANNEL[platform] ?? `no local notification channel on ${platform}`, transport: "local" };
|
|
111
|
+
}
|
|
112
|
+
const title = safeTitle(request.title);
|
|
113
|
+
const body = safeMessage(request.message);
|
|
114
|
+
const args = channel === "osascript" ? ["-e", DARWIN_SCRIPT, DARWIN_ARGV_MARKER, title, body] : channel === "terminal-notifier" ? ["-title", title, "-message", body, "-appName", "LaCode", "-priority", normalizeUrgency(request.urgency)] : (
|
|
115
|
+
// Byte-identical to the notify-send call this package has always made.
|
|
116
|
+
[title, body, `--urgency=${normalizeUrgency(request.urgency)}`, `--icon=${normalizeIcon(request.icon)}`]
|
|
117
|
+
);
|
|
118
|
+
const result = run(CHANNEL_BINARY[channel], args, 5e3);
|
|
93
119
|
return { ok: result.ok, detail: result.ok ? void 0 : result.stderr || result.detail, transport: "local" };
|
|
94
120
|
}
|
|
95
121
|
function sendRemote(request, tunnel) {
|
|
@@ -109,9 +135,9 @@ function sendRemote(request, tunnel) {
|
|
|
109
135
|
const result = run("ssh", args, tunnel.connectTimeoutMs + 2e3);
|
|
110
136
|
return { ok: result.ok, detail: result.ok ? void 0 : result.stderr || result.detail, transport: "remote" };
|
|
111
137
|
}
|
|
112
|
-
function notify(request, env = process.env) {
|
|
138
|
+
function notify(request, env = process.env, platform = process.platform) {
|
|
113
139
|
try {
|
|
114
|
-
return isRemote(env) ? sendRemote(request, resolveTunnel(env)) : sendLocal(request);
|
|
140
|
+
return isRemote(env) ? sendRemote(request, resolveTunnel(env)) : sendLocal(request, platform);
|
|
115
141
|
} catch (error) {
|
|
116
142
|
return { ok: false, detail: String(error), transport: "none" };
|
|
117
143
|
}
|
|
@@ -127,13 +153,16 @@ function forward(host, argv2, options = {}) {
|
|
|
127
153
|
stdout: result.stdout
|
|
128
154
|
};
|
|
129
155
|
}
|
|
130
|
-
function describe(env = process.env) {
|
|
156
|
+
function describe(env = process.env, platform = process.platform) {
|
|
131
157
|
const tunnel = resolveTunnel(env);
|
|
132
158
|
return {
|
|
133
159
|
running: isRemote(env) ? "remote (over ssh)" : "local",
|
|
134
160
|
user: tunnel.user || "(unset)",
|
|
135
161
|
port: tunnel.port,
|
|
136
162
|
key: tunnel.key,
|
|
163
|
+
channel: localChannel(platform),
|
|
164
|
+
// Still exactly what it always meant -- is the notify-send binary present --
|
|
165
|
+
// rather than a second opinion about which channel runs.
|
|
137
166
|
notifySend: have("notify-send")
|
|
138
167
|
};
|
|
139
168
|
}
|
|
@@ -212,6 +241,8 @@ switch (command) {
|
|
|
212
241
|
if (info.running.startsWith("remote")) {
|
|
213
242
|
if (info.user === "(unset)") console.log("\nOC_LOCAL_USER is unset: the hop cannot name the desktop user, and guessing with `id -un` is wrong on most remote accounts.");
|
|
214
243
|
console.log("Set OC_LOCAL_USER, OC_TUNNEL_PORT and OC_NOTIFY_KEY, or point OC_NOTIFY_HELPER at a helper that knows them.");
|
|
244
|
+
} else if (info.channel === "none") {
|
|
245
|
+
console.log("\nNo local notification channel: `lacode notify` on this machine has nothing to deliver with, and every send will be reported as failed rather than shown.");
|
|
215
246
|
}
|
|
216
247
|
process.exit(0);
|
|
217
248
|
break;
|
package/dist/tunnel.js
CHANGED
|
@@ -76,15 +76,41 @@ var have = (command) => {
|
|
|
76
76
|
return false;
|
|
77
77
|
}
|
|
78
78
|
};
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
79
|
+
var CHANNEL_BINARY = {
|
|
80
|
+
"notify-send": "notify-send",
|
|
81
|
+
osascript: "osascript",
|
|
82
|
+
"terminal-notifier": "terminal-notifier"
|
|
83
|
+
};
|
|
84
|
+
var DARWIN_SCRIPT = [
|
|
85
|
+
"on run argv",
|
|
86
|
+
" display notification (item 3 of argv) with title (item 2 of argv)",
|
|
87
|
+
"end run"
|
|
88
|
+
].join("\n");
|
|
89
|
+
var DARWIN_ARGV_MARKER = "lacode";
|
|
90
|
+
function localChannel(platform = process.platform) {
|
|
91
|
+
if (platform === "darwin") {
|
|
92
|
+
if (have(CHANNEL_BINARY.osascript)) return "osascript";
|
|
93
|
+
if (have(CHANNEL_BINARY["terminal-notifier"])) return "terminal-notifier";
|
|
94
|
+
return "none";
|
|
95
|
+
}
|
|
96
|
+
return have(CHANNEL_BINARY["notify-send"]) ? "notify-send" : "none";
|
|
97
|
+
}
|
|
98
|
+
var NO_CHANNEL = {
|
|
99
|
+
linux: "notify-send is not installed",
|
|
100
|
+
darwin: "neither osascript nor terminal-notifier is installed"
|
|
101
|
+
};
|
|
102
|
+
function sendLocal(request, platform = process.platform) {
|
|
103
|
+
const channel = localChannel(platform);
|
|
104
|
+
if (channel === "none") {
|
|
105
|
+
return { ok: false, detail: NO_CHANNEL[platform] ?? `no local notification channel on ${platform}`, transport: "local" };
|
|
106
|
+
}
|
|
107
|
+
const title = safeTitle(request.title);
|
|
108
|
+
const body = safeMessage(request.message);
|
|
109
|
+
const args = channel === "osascript" ? ["-e", DARWIN_SCRIPT, DARWIN_ARGV_MARKER, title, body] : channel === "terminal-notifier" ? ["-title", title, "-message", body, "-appName", "LaCode", "-priority", normalizeUrgency(request.urgency)] : (
|
|
110
|
+
// Byte-identical to the notify-send call this package has always made.
|
|
111
|
+
[title, body, `--urgency=${normalizeUrgency(request.urgency)}`, `--icon=${normalizeIcon(request.icon)}`]
|
|
112
|
+
);
|
|
113
|
+
const result = run(CHANNEL_BINARY[channel], args, 5e3);
|
|
88
114
|
return { ok: result.ok, detail: result.ok ? void 0 : result.stderr || result.detail, transport: "local" };
|
|
89
115
|
}
|
|
90
116
|
function sendRemote(request, tunnel) {
|
|
@@ -104,9 +130,9 @@ function sendRemote(request, tunnel) {
|
|
|
104
130
|
const result = run("ssh", args, tunnel.connectTimeoutMs + 2e3);
|
|
105
131
|
return { ok: result.ok, detail: result.ok ? void 0 : result.stderr || result.detail, transport: "remote" };
|
|
106
132
|
}
|
|
107
|
-
function notify(request, env = process.env) {
|
|
133
|
+
function notify(request, env = process.env, platform = process.platform) {
|
|
108
134
|
try {
|
|
109
|
-
return isRemote(env) ? sendRemote(request, resolveTunnel(env)) : sendLocal(request);
|
|
135
|
+
return isRemote(env) ? sendRemote(request, resolveTunnel(env)) : sendLocal(request, platform);
|
|
110
136
|
} catch (error) {
|
|
111
137
|
return { ok: false, detail: String(error), transport: "none" };
|
|
112
138
|
}
|
|
@@ -122,13 +148,16 @@ function forward(host, argv, options = {}) {
|
|
|
122
148
|
stdout: result.stdout
|
|
123
149
|
};
|
|
124
150
|
}
|
|
125
|
-
function describe(env = process.env) {
|
|
151
|
+
function describe(env = process.env, platform = process.platform) {
|
|
126
152
|
const tunnel = resolveTunnel(env);
|
|
127
153
|
return {
|
|
128
154
|
running: isRemote(env) ? "remote (over ssh)" : "local",
|
|
129
155
|
user: tunnel.user || "(unset)",
|
|
130
156
|
port: tunnel.port,
|
|
131
157
|
key: tunnel.key,
|
|
158
|
+
channel: localChannel(platform),
|
|
159
|
+
// Still exactly what it always meant -- is the notify-send binary present --
|
|
160
|
+
// rather than a second opinion about which channel runs.
|
|
132
161
|
notifySend: have("notify-send")
|
|
133
162
|
};
|
|
134
163
|
}
|
|
@@ -139,6 +168,7 @@ export {
|
|
|
139
168
|
encodePayload,
|
|
140
169
|
forward,
|
|
141
170
|
isRemote,
|
|
171
|
+
localChannel,
|
|
142
172
|
normalizeIcon,
|
|
143
173
|
normalizeUrgency,
|
|
144
174
|
notify,
|
package/docs/TUNNEL.md
CHANGED
|
@@ -46,7 +46,9 @@ spaces, quotes or `*` survives intact.
|
|
|
46
46
|
## Sending
|
|
47
47
|
|
|
48
48
|
The CLI picks the transport by where it is running — `SSH_CLIENT` or `SSH_TTY`
|
|
49
|
-
means remote, so it forwards; otherwise it
|
|
49
|
+
means remote, so it forwards; otherwise it delivers locally through whichever
|
|
50
|
+
desktop notifier this platform has: `notify-send` everywhere else, `osascript` on
|
|
51
|
+
macOS, with `terminal-notifier` as the macOS fallback when `osascript` is missing.
|
|
50
52
|
|
|
51
53
|
```bash
|
|
52
54
|
lacode notify "build finished" "lapode: 4 files changed" normal dialog-information
|
|
@@ -55,12 +57,17 @@ lacode forward web-01 -- systemctl --user status oc-tunnel
|
|
|
55
57
|
lacode doctor
|
|
56
58
|
```
|
|
57
59
|
|
|
58
|
-
`notify` is fire-and-forget by design. An unreachable desktop, a missing key,
|
|
59
|
-
|
|
60
|
+
`notify` is fire-and-forget by design. An unreachable desktop, a missing key, no
|
|
61
|
+
notifier installed at all — all of them print a warning on stderr and exit 0. A
|
|
60
62
|
notification must never be able to fail the process that tried to send one; an
|
|
61
63
|
agent must not go down because a laptop went to sleep. `forward` is the
|
|
62
64
|
exception: it exits with the remote's status, because its caller wants to know.
|
|
63
65
|
|
|
66
|
+
On macOS the text is passed to `osascript` as arguments to an `on run argv`
|
|
67
|
+
handler, never spliced into the script source — a session title is not trusted
|
|
68
|
+
input. `lacode doctor` prints the channel that would actually run, so it is
|
|
69
|
+
useful on a Mac rather than reporting only whether a Linux binary is installed.
|
|
70
|
+
|
|
64
71
|
`lacode doctor` prints what the environment resolves to and never gates on it.
|
|
65
72
|
|
|
66
73
|
## Configuration
|
|
@@ -80,10 +87,12 @@ nobody's desktop. Guessing the desktop user is worse than failing.
|
|
|
80
87
|
## Setting the tunnel up on the desktop
|
|
81
88
|
|
|
82
89
|
The receiver runs on the machine with the desktop, as an `authorized_keys`
|
|
83
|
-
forced command restricted to that one script
|
|
90
|
+
forced command restricted to that one script. `$HOME` must be the expanded
|
|
91
|
+
absolute path — a `~` is not expanded there, and an unexpanded one produces an
|
|
92
|
+
`authorized_keys` entry that names a file that does not exist:
|
|
84
93
|
|
|
85
94
|
```
|
|
86
|
-
restrict,command="/home/
|
|
95
|
+
restrict,command="/home/yourname/.local/bin/lacode-notify-receiver" ssh-ed25519 AAAA… oc-notify tunnel auth
|
|
87
96
|
```
|
|
88
97
|
|
|
89
98
|
`restrict` is doing the security work: no shell, no PTY, no agent forwarding, no
|
|
@@ -96,12 +105,36 @@ The receiver must satisfy this contract:
|
|
|
96
105
|
argument list — it is attacker-controlled, because it is whatever the remote
|
|
97
106
|
user typed;
|
|
98
107
|
3. split it on newlines into title / message / urgency / icon, each with a
|
|
99
|
-
default
|
|
108
|
+
default — **using only constructs bash 3.2 has**, not just bash 4+;
|
|
100
109
|
4. whitelist `urgency` against `low|normal|critical` and `icon` against the six
|
|
101
110
|
supported names, both of which become command-line flags;
|
|
102
111
|
5. resolve `DISPLAY` / `DBUS_SESSION_BUS_ADDRESS` for the session it is running
|
|
103
112
|
in, then `exec notify-send "$TITLE" "$MSG" --urgency=… --icon=…`.
|
|
104
113
|
|
|
114
|
+
Point 3 is not a detail. `mapfile`/`readarray` are bash 4.0+, and where they are
|
|
115
|
+
missing — macOS ships bash 3.2, as do some minimal Linux images — the array is
|
|
116
|
+
simply never built, every field falls back to its default, and the desktop gets
|
|
117
|
+
a notification titled "LaCode" with an empty body. No error is printed anywhere
|
|
118
|
+
that anyone will read, because the receiver's stderr goes to whoever happened to
|
|
119
|
+
open the ssh connection. A `while IFS= read -r` loop is equivalent and works
|
|
120
|
+
everywhere; `bin/oc-notify-receiver` uses one.
|
|
121
|
+
|
|
122
|
+
### This half of the hop is Linux
|
|
123
|
+
|
|
124
|
+
The receiver delivers through `notify-send`, and the forward that reaches it is
|
|
125
|
+
a systemd `--user` unit (below). Both are Linux, so **the desktop half of this
|
|
126
|
+
tunnel does not exist on macOS**, and that is by design rather than a gap.
|
|
127
|
+
|
|
128
|
+
On a Mac you do not need it: `lacode notify` run locally picks `osascript` (or
|
|
129
|
+
`terminal-notifier`) and shows the notification itself, with no tunnel, no
|
|
130
|
+
`authorized_keys` entry and no systemd unit involved. The tunnel exists for the
|
|
131
|
+
case opencode runs *somewhere else*, and a Mac is not going to be running it.
|
|
132
|
+
|
|
133
|
+
If you point the receiver at a machine with no `notify-send` anyway, it exits
|
|
134
|
+
non-zero and says so, naming `lacode notify` as the path that does work there.
|
|
135
|
+
`bin/oc-sync` refuses the same machine for the same reason, and names each
|
|
136
|
+
missing prerequisite instead of failing somewhere less obvious.
|
|
137
|
+
|
|
105
138
|
> **Note.** The published package ships the built CLI (`dist/lacode.js`, linked
|
|
106
139
|
> as `lacode`) and the tunnel behind it (`dist/tunnel.js`, importable as
|
|
107
140
|
> `@lawaty/lacode/tunnel`), not a receiver script. The receiver is the
|
package/docs/USER-STORIES.md
CHANGED
|
@@ -246,6 +246,24 @@ does not.
|
|
|
246
246
|
- [ ] Given a payload containing shell metacharacters (`$(…)`, backticks, `;`, `&&`) or extra fields, When the receiver handles it, Then it is parsed as data only: nothing is executed and the argument list passed to `notify-send` is unchanged.
|
|
247
247
|
- [ ] Given a payload that cannot be understood at all, When it arrives, Then the receiver still exits 0 and pops up what it received, rather than failing the hop.
|
|
248
248
|
|
|
249
|
+
### US-26: A local notification is delivered on macOS, not only on Linux
|
|
250
|
+
**As a** user whose desktop is a Mac, **I want** a notification to actually appear when a session finishes, **so that** installing the package on macOS is not the same as getting no notifications at all.
|
|
251
|
+
|
|
252
|
+
**Story ID(s) for tests:** US-26
|
|
253
|
+
**Acceptance criteria:**
|
|
254
|
+
- [ ] Given the notification is raised on macOS, When it is delivered locally, Then it goes to `osascript` — or to `terminal-notifier` if `osascript` is not installed — and not to `notify-send`, which does not exist there. Before this, the send exited 0, printed nothing, and popped up nothing.
|
|
255
|
+
- [ ] Given the channel is chosen, When it is picked, Then it is decided by `process.platform` and by which binary is actually installed, never by guessing, and every platform other than macOS keeps the `notify-send` call it has always made.
|
|
256
|
+
- [ ] Given the title and the body reach `osascript`, When the script runs, Then they are passed as arguments to an `on run argv` handler and never concatenated into the script source, because both fields are attacker-influenced text — a session title, a file path, a command.
|
|
257
|
+
- [ ] Given a body containing `"`, `'`, `;`, `$(…)` or backticks, When the macOS send runs, Then it reaches `display notification` byte-for-byte as data and nothing is executed.
|
|
258
|
+
- [ ] Given a machine has no notifier for its platform, When a notification is sent, Then the result is a failed delivery with a reason and no exception, and `lacode doctor` names the channel that would run — or reports that there is none — instead of reporting a binary that is not used there.
|
|
259
|
+
- [ ] Given the CLI runs on macOS, When the send does not complete, Then it is still bounded by a timeout, exactly as on Linux.
|
|
260
|
+
- [ ] Given the repository's own shell scripts, When each is classified, Then the doc says which side of the hop it runs on — `bin/oc` and `bin/oc-notify` on the remote host that runs opencode, `bin/oc-notify-receiver` and `bin/oc-sync` on the desktop — and says plainly why the desktop pair is Linux-only (a `notify-send` channel behind a systemd user unit), so a Mac user is not sent to debug an ssh script that was never meant to run on their machine.
|
|
261
|
+
- [ ] Given a machine without GNU `timeout` or with bash older than 4.0, When `bin/oc-sync` runs, Then it refuses **before** touching anything, names each missing prerequisite, and names the `lacode` subcommand to use instead. It must never report a misleading cause: the host list is read with `mapfile`, so without it the run used to die at "no hosts configured" — a confident wrong answer that sends the reader to edit `.env`.
|
|
262
|
+
- [ ] Given `bin/oc-notify-receiver` runs where `notify-send` does not exist, When it is invoked, Then it exits non-zero and names both the missing channel and the portable path, because its stderr reaches whoever opened the ssh connection and nobody else, and a bare "command not found" reads as a desktop that quietly stopped notifying.
|
|
263
|
+
- [ ] Given the payload is parsed, When the receiver splits `$SSH_ORIGINAL_COMMAND`, Then it splits on newlines only using constructs that exist in bash 3.2 as well as bash 5, and the suite **proves the split by running the receiver** rather than by finding the word `mapfile` in the file. Under bash 3.2 — the version macOS ships — a bash-4-only builtin left every field at its default, so the popup said "LaCode" with an empty body and no error anywhere: the same silent blank notification the argv bug caused once, reached again through a different door.
|
|
264
|
+
- [ ] Given the whole suite, When it runs on macOS, Then it passes there too. Checks that exercise the Linux-only shell scripts **skip** on a machine that cannot run them, each naming the specific missing prerequisite (a bash version below 4.0, an absent GNU `timeout`) rather than passing silently.
|
|
265
|
+
- [ ] Given the local notification channel differs by platform, When a test asserts on the notifier's argv, Then it reads **every** channel's stub rather than only `notify-send`. A Mac picks `osascript` and never invokes `notify-send` at all, so a suite that stubbed only `notify-send` would pass on Linux, read an empty log on a Mac, and in the process invoke the runner's real `osascript` against a desktop session that is not there.
|
|
266
|
+
|
|
249
267
|
---
|
|
250
268
|
|
|
251
269
|
## Epic F — Adoption and safety
|
|
@@ -258,6 +276,12 @@ does not.
|
|
|
258
276
|
- [ ] Given a fresh machine with no LaCode config, When the install runs, Then one config line is sufficient and no further manual step is required.
|
|
259
277
|
- [ ] Given no configuration is present at all, When LaCode boots, Then it falls back to shipped defaults and still functions.
|
|
260
278
|
- [ ] Given the plugin package is installed, When its peer dependency is unmet, Then the requirement is declared rather than silently duplicated inside the package.
|
|
279
|
+
- [ ] Given the published manifest, When a plugin loader looks for an entry point, Then it finds one in both shapes a resolver may consult — a top-level `main` and `exports["./server"]` — while `exports["."]` keeps pointing at the same built plugin, and the documentation states plainly that these are forward-compatibility metadata and **not** a fix for opencode 1.18.35, where adding them to the installed copy was tested and the bare specifier still loaded nothing.
|
|
280
|
+
- [ ] Given an opencode that does not load the bare specifier, When the reader follows the install documentation, Then the README offers a numbered procedure that holds **whichever version they run** — write the bare specifier and restart, verify with `opencode agent list`, and only if the agents are still missing while the package is installed, add a `file://` entry and restart — rather than instructions scoped to one build. Each step is justified by what the previous step showed, not by what the reader's version is known to do.
|
|
281
|
+
- [ ] Given a version nobody has tested, When the reader checks the version table, Then **every** row other than the measured one is marked untested or unknown, the README claims no loading behaviour for any version it has not run, and it tells the reader to follow the procedure rather than to read the table as coverage of their build. The one row that does claim behaviour records the evidence behind it, and says that installing the package and then silently dropping it is what was measured.
|
|
282
|
+
- [ ] Given any operating system, When the reader needs the package directory to build a `file://` entry, Then the path comes from `opencode debug paths` rather than a hardcoded root, and any literal path in the document is marked as an illustration of one platform's layout instead of a value to copy. Where a platform's cache root has not been established, the README says so rather than guessing it.
|
|
283
|
+
- [ ] Given a macOS user, When they look for what works on their machine, Then the README documents macOS: the plugin runs there, notifications are delivered through `osascript` or `terminal-notifier`, paths still come from `opencode debug paths` with no asserted macOS cache root, and the remote-host and tunnel scripts are marked Linux-only rather than implied to run there.
|
|
284
|
+
- [ ] Given a user whose cache holds an installed package, When the plugin's agents are nevertheless absent, Then the README distinguishes that from a failed install (an empty cache directory) and points at the loader workaround, so "installed" and "loaded" are never reported as the same fact.
|
|
261
285
|
|
|
262
286
|
### US-16: Merges, never clobbers
|
|
263
287
|
**As a** user with an existing opencode config, **I want** LaCode to add its agents/commands only where absent and never overwrite my own definitions, **so that** installing it cannot break my setup.
|
|
@@ -378,6 +402,7 @@ table was filled in.
|
|
|
378
402
|
| US-13 | E | `tests/stories/e-remote-ssh.test.mjs`, `tests/notify-test.mjs`, `tests/oc-test.mjs` |
|
|
379
403
|
| US-14 | E | `tests/stories/e-remote-ssh.test.mjs`, `tests/oc-test.mjs` |
|
|
380
404
|
| US-18 | E | `tests/stories/e-remote-ssh.test.mjs`, `tests/oc-test.mjs` |
|
|
405
|
+
| US-26 | E | `tests/stories/e-remote-ssh.test.mjs`, `tests/oc-test.mjs` |
|
|
381
406
|
| US-15 | F | `tests/stories/f-adoption-safety.test.mjs` |
|
|
382
407
|
| US-16 | F | `tests/stories/f-adoption-safety.test.mjs` |
|
|
383
408
|
| US-17 | F | `tests/stories/f-adoption-safety.test.mjs` |
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lawaty/lacode",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.1",
|
|
4
4
|
"description": "Ultimate opencode setup for Go users — cost-controlled pooled subagents, hang recovery, and notifications for opencode.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -27,8 +27,10 @@
|
|
|
27
27
|
"engines": {
|
|
28
28
|
"node": ">=22.18"
|
|
29
29
|
},
|
|
30
|
-
"
|
|
30
|
+
"main": "./dist/index.js",
|
|
31
|
+
"exports": {
|
|
31
32
|
".": "./dist/index.js",
|
|
33
|
+
"./server": "./dist/index.js",
|
|
32
34
|
"./pool": "./dist/pool.js",
|
|
33
35
|
"./agents": "./dist/agents.js",
|
|
34
36
|
"./commands": "./dist/commands.js",
|