@streamorchard/grapevine 0.5.0 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +25 -3
- package/package.json +5 -3
- package/scripts/check-shipped.js +131 -0
- package/src/index.js +8 -1
package/README.md
CHANGED
|
@@ -28,10 +28,17 @@ npm install @streamorchard/grapevine
|
|
|
28
28
|
|
|
29
29
|
## Use it
|
|
30
30
|
|
|
31
|
+
Say what your app speaks in its `package.json`. It's the one place it's written, and every release tag keeps it, which is how `grapevine-check` knows what each release shipped (see [Recording releases](#recording-releases)):
|
|
32
|
+
|
|
33
|
+
```json
|
|
34
|
+
"grapevine": { "app": "com.streamorchard.peardrops", "provides": { "drops.farming": "1.0" }, "uses": { "players": "1.0" } }
|
|
35
|
+
```
|
|
36
|
+
|
|
31
37
|
```js
|
|
32
38
|
const { app, safeStorage } = require('electron');
|
|
33
39
|
const path = require('path');
|
|
34
40
|
const { createLink, fileKeyStore } = require('@streamorchard/grapevine');
|
|
41
|
+
const { grapevine } = require('./package.json');
|
|
35
42
|
|
|
36
43
|
const link = createLink({
|
|
37
44
|
app: {
|
|
@@ -41,8 +48,8 @@ const link = createLink({
|
|
|
41
48
|
version: app.getVersion(),
|
|
42
49
|
icon: path.join(__dirname, 'icon.png'),
|
|
43
50
|
},
|
|
44
|
-
provides:
|
|
45
|
-
uses:
|
|
51
|
+
provides: grapevine.provides, // what it tells other apps
|
|
52
|
+
uses: grapevine.uses, // what it listens to
|
|
46
53
|
allowed: (appId) => settings.connectedApps[appId]?.enabled === true, // your Connected apps switches
|
|
47
54
|
keys: fileKeyStore(path.join(app.getPath('userData'), 'grapevine.json'), {
|
|
48
55
|
encrypt: (t) => safeStorage.encryptString(t).toString('base64'),
|
|
@@ -177,7 +184,22 @@ Add it to `src/known-apps.js` with:
|
|
|
177
184
|
|
|
178
185
|
Every app that updates Grapevine then shows it on its Connected apps page, with what the two would share. `link.peers()` gives that as `shares`, from this list until they meet and from the hello after.
|
|
179
186
|
|
|
180
|
-
When it ships a release that uses Grapevine, record it
|
|
187
|
+
When it ships a release that uses Grapevine, record it (next section).
|
|
188
|
+
|
|
189
|
+
## Recording releases
|
|
190
|
+
|
|
191
|
+
Once an app ships a capability version, Grapevine records it. From then on that version can't change in place, and other apps can say which release to update to.
|
|
192
|
+
|
|
193
|
+
In the app's repo, at the start of every change:
|
|
194
|
+
|
|
195
|
+
```sh
|
|
196
|
+
npx grapevine-check
|
|
197
|
+
```
|
|
198
|
+
|
|
199
|
+
- **It reads** the `grapevine` field from each of the app's release tags (`v*`), and Grapevine's record from GitHub (the installed copy if it's offline, or with `--offline`).
|
|
200
|
+
- **It lists** any capability version a release shipped that isn't recorded yet, with the command to record it, e.g. `npm run released -- com.streamorchard.peardrops 0.1.6 drops.farming@1.0 players@1.0`.
|
|
201
|
+
- **That command runs in a Grapevine checkout** (its `app-shipped` skill), followed by a pull request and a patch release.
|
|
202
|
+
- **`--strict`** exits 1 while something is unrecorded, for CI.
|
|
181
203
|
|
|
182
204
|
## Renaming an app
|
|
183
205
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@streamorchard/grapevine",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.0",
|
|
4
4
|
"description": "Lets desktop apps on the same computer find each other and talk, privately: local sockets, JSON-RPC, pairing and versioned capabilities.",
|
|
5
5
|
"license": "GPL-3.0-only",
|
|
6
6
|
"author": "StreamOrchard",
|
|
@@ -32,7 +32,8 @@
|
|
|
32
32
|
"./package.json": "./package.json"
|
|
33
33
|
},
|
|
34
34
|
"bin": {
|
|
35
|
-
"grapevine-stand-in": "stand-in-peer.js"
|
|
35
|
+
"grapevine-stand-in": "stand-in-peer.js",
|
|
36
|
+
"grapevine-check": "scripts/check-shipped.js"
|
|
36
37
|
},
|
|
37
38
|
"files": [
|
|
38
39
|
"src",
|
|
@@ -41,7 +42,8 @@
|
|
|
41
42
|
"fixtures",
|
|
42
43
|
"assets/apps",
|
|
43
44
|
"stand-in-peer.js",
|
|
44
|
-
"PROTOCOL.md"
|
|
45
|
+
"PROTOCOL.md",
|
|
46
|
+
"scripts/check-shipped.js"
|
|
45
47
|
],
|
|
46
48
|
"engines": {
|
|
47
49
|
"node": ">=22"
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Run in an app's repo (npx grapevine-check): lists the capability versions the app's releases
|
|
3
|
+
// have shipped that Grapevine hasn't recorded yet, with the command that records them (Grapevine's
|
|
4
|
+
// app-shipped skill). Until they're recorded, those versions can still be changed in place, and
|
|
5
|
+
// other apps can't say which release to update to.
|
|
6
|
+
//
|
|
7
|
+
// The app says what it speaks in its package.json, so every release tag has it:
|
|
8
|
+
// "grapevine": { "app": "com.streamorchard.peardrops", "provides": { "drops.farming": "1.0" }, "uses": { "players": "1.0" } }
|
|
9
|
+
//
|
|
10
|
+
// npx grapevine-check prints what needs recording (exit 0)
|
|
11
|
+
// npx grapevine-check --strict exits 1 when something does
|
|
12
|
+
// npx grapevine-check --offline uses the record in the installed package, not GitHub's
|
|
13
|
+
'use strict';
|
|
14
|
+
|
|
15
|
+
const fs = require('fs');
|
|
16
|
+
const path = require('path');
|
|
17
|
+
const { execFileSync } = require('child_process');
|
|
18
|
+
|
|
19
|
+
const RECORD_URL = 'https://raw.githubusercontent.com/Stream-Orchard/grapevine/main/schemas/released.json';
|
|
20
|
+
const INSTALLED = path.join(__dirname, '..', 'schemas', 'released.json');
|
|
21
|
+
const VERSION = /^v?(\d+)\.(\d+)\.(\d+)$/;
|
|
22
|
+
|
|
23
|
+
/** "capability@major.minor" for each capability a package.json's grapevine field provides or uses. */
|
|
24
|
+
function declaredBy(grapevine) {
|
|
25
|
+
const out = new Set();
|
|
26
|
+
for (const list of [grapevine?.provides, grapevine?.uses]) {
|
|
27
|
+
for (const [cap, versions] of Object.entries(list ?? {})) for (const v of [].concat(versions)) out.add(`${cap}@${v}`);
|
|
28
|
+
}
|
|
29
|
+
return [...out].sort();
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const byVersion = (a, b) => {
|
|
33
|
+
const [x, y] = [VERSION.exec(a.version), VERSION.exec(b.version)];
|
|
34
|
+
for (let i = 1; i <= 3; i++) if (Number(x[i]) !== Number(y[i])) return Number(x[i]) - Number(y[i]);
|
|
35
|
+
return 0;
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* What hasn't been recorded: for each capability version any release declared, the first release
|
|
40
|
+
* that did, unless the record already has it.
|
|
41
|
+
* @param {{version: string, grapevine: any}[]} releases every release tag, with its package.json's grapevine field (or none)
|
|
42
|
+
* @param {{firstRelease: Record<string, Record<string, string>>}} released Grapevine's record
|
|
43
|
+
* @returns {{app: string, version: string, capabilities: string[], command: string}[]} one per release to record, oldest first
|
|
44
|
+
*/
|
|
45
|
+
function findUnrecorded(releases, released) {
|
|
46
|
+
const first = new Map(); // "app capability@version" -> the first release with it
|
|
47
|
+
for (const r of [...releases].filter((x) => VERSION.test(x.version)).sort(byVersion)) {
|
|
48
|
+
if (!r.grapevine?.app) continue;
|
|
49
|
+
for (const cap of declaredBy(r.grapevine)) {
|
|
50
|
+
const key = `${r.grapevine.app} ${cap}`;
|
|
51
|
+
if (!first.has(key)) first.set(key, { app: r.grapevine.app, version: r.version.replace(/^v/, ''), cap });
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
const todo = new Map(); // "app version" -> capabilities
|
|
55
|
+
for (const { app, version, cap } of first.values()) {
|
|
56
|
+
if (released?.firstRelease?.[app]?.[cap]) continue;
|
|
57
|
+
const key = `${app} ${version}`;
|
|
58
|
+
todo.set(key, { app, version, capabilities: [...(todo.get(key)?.capabilities ?? []), cap] });
|
|
59
|
+
}
|
|
60
|
+
return [...todo.values()]
|
|
61
|
+
.sort((a, b) => byVersion(a, b))
|
|
62
|
+
.map((t) => ({ ...t, command: `npm run released -- ${t.app} ${t.version} ${t.capabilities.join(' ')}` }));
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const git = (cwd, args) => execFileSync('git', args, { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] });
|
|
66
|
+
|
|
67
|
+
/** Every v* tag in the repo, with its package.json's grapevine field. */
|
|
68
|
+
function releasesIn(cwd) {
|
|
69
|
+
const tags = git(cwd, ['tag', '-l', 'v*'])
|
|
70
|
+
.split('\n')
|
|
71
|
+
.map((t) => t.trim())
|
|
72
|
+
.filter((t) => VERSION.test(t));
|
|
73
|
+
return tags.map((tag) => {
|
|
74
|
+
let grapevine = null;
|
|
75
|
+
try {
|
|
76
|
+
grapevine = JSON.parse(git(cwd, ['show', `${tag}:package.json`])).grapevine ?? null;
|
|
77
|
+
} catch {}
|
|
78
|
+
return { version: tag, grapevine };
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Grapevine's record: GitHub's copy (the newest), or the installed package's when offline or it can't be fetched. */
|
|
83
|
+
async function loadRecord({ offline = false, fetchImpl = globalThis.fetch, installed = INSTALLED } = {}) {
|
|
84
|
+
if (!offline && fetchImpl) {
|
|
85
|
+
try {
|
|
86
|
+
const res = await fetchImpl(RECORD_URL, { signal: AbortSignal.timeout(5000) });
|
|
87
|
+
if (res.ok) return { record: await res.json(), source: 'GitHub' };
|
|
88
|
+
} catch {}
|
|
89
|
+
}
|
|
90
|
+
return { record: JSON.parse(fs.readFileSync(installed, 'utf8')), source: 'the installed package (it may be behind GitHub)' };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* @param {string[]} argv
|
|
95
|
+
* @param {{cwd?: string, out?: (text: string) => void, fetchImpl?: typeof fetch}} [opts]
|
|
96
|
+
*/
|
|
97
|
+
async function main(argv, { cwd = process.cwd(), out = (t) => void process.stdout.write(t), fetchImpl = globalThis.fetch } = {}) {
|
|
98
|
+
const strict = argv.includes('--strict');
|
|
99
|
+
const offline = argv.includes('--offline');
|
|
100
|
+
try {
|
|
101
|
+
if (!offline) git(cwd, ['fetch', '--tags', '--quiet']);
|
|
102
|
+
} catch {} // no remote, or offline: the tags already here
|
|
103
|
+
const releases = releasesIn(cwd);
|
|
104
|
+
if (!releases.some((r) => r.grapevine?.app)) {
|
|
105
|
+
out('No release of this app declares Grapevine capabilities yet (package.json "grapevine"). Nothing to record.\n');
|
|
106
|
+
return 0;
|
|
107
|
+
}
|
|
108
|
+
const { record, source } = await loadRecord({ offline, fetchImpl });
|
|
109
|
+
const todo = findUnrecorded(releases, record);
|
|
110
|
+
if (!todo.length) {
|
|
111
|
+
out(`Every capability version this app has released is recorded in Grapevine (checked against ${source}).\n`);
|
|
112
|
+
return 0;
|
|
113
|
+
}
|
|
114
|
+
out(`Grapevine hasn't recorded what these releases shipped (checked against ${source}):\n`);
|
|
115
|
+
for (const t of todo) out(` ${t.version}: ${t.capabilities.join(', ')}\n`);
|
|
116
|
+
out(`Record them in a Grapevine checkout (its app-shipped skill), then a pull request and a patch release:\n`);
|
|
117
|
+
for (const t of todo) out(` ${t.command}\n`);
|
|
118
|
+
return strict ? 1 : 0;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
if (require.main === module) {
|
|
122
|
+
main(process.argv.slice(2)).then(
|
|
123
|
+
(code) => process.exit(code),
|
|
124
|
+
(err) => {
|
|
125
|
+
process.stderr.write(`grapevine-check: ${err.message}\n`);
|
|
126
|
+
process.exit(2);
|
|
127
|
+
},
|
|
128
|
+
);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
module.exports = { declaredBy, findUnrecorded, releasesIn, loadRecord, main, RECORD_URL };
|
package/src/index.js
CHANGED
|
@@ -99,7 +99,14 @@ class Link extends EventEmitter {
|
|
|
99
99
|
const server = net.createServer((socket) => this.accept(socket));
|
|
100
100
|
const go = () =>
|
|
101
101
|
server.listen(this.socketPath, () => {
|
|
102
|
-
|
|
102
|
+
// The socket can vanish before this runs (its folder removed): start() fails, rather than
|
|
103
|
+
// an exception the app can't catch.
|
|
104
|
+
try {
|
|
105
|
+
if (platform !== 'win32') fs.chmodSync(this.socketPath, 0o600);
|
|
106
|
+
} catch (err) {
|
|
107
|
+
server.close();
|
|
108
|
+
return reject(err);
|
|
109
|
+
}
|
|
103
110
|
this.server = server;
|
|
104
111
|
resolve();
|
|
105
112
|
});
|