datagrok-tools 6.7.8 → 6.7.10
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/.devcontainer/Dockerfile.pkg_dev +1 -1
- package/.turbo/turbo-build.log +2 -2
- package/CHANGELOG.md +33 -0
- package/CLAUDE.md +13 -0
- package/GROK_S.md +138 -6
- package/README.md +14 -0
- package/bin/commands/add.js +4 -4
- package/bin/commands/api.js +49 -20
- package/bin/commands/check.js +43 -1
- package/bin/commands/connect-folder.js +411 -0
- package/bin/commands/help.js +68 -2
- package/bin/commands/publish.js +6 -0
- package/bin/commands/server-domains.js +24 -7
- package/bin/commands/server-observe.js +854 -0
- package/bin/commands/server.js +72 -5
- package/bin/commands/src.js +458 -0
- package/bin/grok.js +9 -5
- package/bin/utils/connect-folder/docker.js +244 -0
- package/bin/utils/connect-folder/docker.ts +259 -0
- package/bin/utils/ent-helpers.js +2 -2
- package/bin/utils/migrate/walker.js +2 -1
- package/bin/utils/migrate/walker.ts +1 -1
- package/bin/utils/node-dapi.js +56 -17
- package/bin/utils/server-client.js +8 -1
- package/bin/utils/test-utils.js +54 -2
- package/domain-schema.schema.json +50 -1
- package/entity-template/domain-app-imports.js +2 -2
- package/package.json +5 -2
- package/plugins/func-gen-plugin.js +1 -1
- package/vitest.config.mts +4 -0
|
@@ -0,0 +1,411 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
var _interopRequireDefault = require("@babel/runtime/helpers/interopRequireDefault");
|
|
4
|
+
Object.defineProperty(exports, "__esModule", {
|
|
5
|
+
value: true
|
|
6
|
+
});
|
|
7
|
+
exports.checkFolderSafety = checkFolderSafety;
|
|
8
|
+
exports.connectFolder = connectFolder;
|
|
9
|
+
exports.matches = matches;
|
|
10
|
+
exports.withKeyFile = withKeyFile;
|
|
11
|
+
var _fs = _interopRequireDefault(require("fs"));
|
|
12
|
+
var _os = _interopRequireDefault(require("os"));
|
|
13
|
+
var _path = _interopRequireDefault(require("path"));
|
|
14
|
+
var color = _interopRequireWildcard(require("../utils/color-utils"));
|
|
15
|
+
var _keypair = require("../utils/keypair");
|
|
16
|
+
var _serverClient = require("../utils/server-client");
|
|
17
|
+
var _nodeDapi = require("../utils/node-dapi");
|
|
18
|
+
var _serverOutput = require("../utils/server-output");
|
|
19
|
+
var dk = _interopRequireWildcard(require("../utils/connect-folder/docker"));
|
|
20
|
+
function _interopRequireWildcard(e, t) { if ("function" == typeof WeakMap) var r = new WeakMap(), n = new WeakMap(); return (_interopRequireWildcard = function (e, t) { if (!t && e && e.__esModule) return e; var o, i, f = { __proto__: null, default: e }; if (null === e || "object" != typeof e && "function" != typeof e) return f; if (o = t ? n : r) { if (o.has(e)) return o.get(e); o.set(e, f); } for (const t in e) "default" !== t && {}.hasOwnProperty.call(e, t) && ((i = (o = Object.defineProperty) && Object.getOwnPropertyDescriptor(e, t)) && (i.get || i.set) ? o(f, t, i) : f[t] = e[t]); return f; })(e, t); }
|
|
21
|
+
/**
|
|
22
|
+
* `grok connect-folder <folder>` - makes a local folder a Datagrok connection. The connection is created over REST
|
|
23
|
+
* with the keypair session, and served by the proxy-storage agent in a Docker container that dials out
|
|
24
|
+
* to the server; `list`, `start`, `stop`, `rm` and `logs` manage the containers of this machine.
|
|
25
|
+
*/
|
|
26
|
+
async function connectFolder(args) {
|
|
27
|
+
const follow = flag(args, 'f') || args.follow === true;
|
|
28
|
+
const output = args.output ?? args.o ?? 'table';
|
|
29
|
+
(0, _serverOutput.setOutputFormat)(output);
|
|
30
|
+
const [, sub, ref, ...extra] = args._.map(a => `${a}`);
|
|
31
|
+
if (sub == null || extra.length > 0) return false;
|
|
32
|
+
try {
|
|
33
|
+
switch (sub) {
|
|
34
|
+
case 'list':
|
|
35
|
+
return ref == null && (await list(args.host, output));
|
|
36
|
+
case 'start':
|
|
37
|
+
case 'stop':
|
|
38
|
+
case 'logs':
|
|
39
|
+
return ref != null && (await manage(sub, ref, args.host, follow));
|
|
40
|
+
case 'rm':
|
|
41
|
+
return ref != null && (await remove(ref, args.host));
|
|
42
|
+
default:
|
|
43
|
+
return ref == null && (await create(sub, {
|
|
44
|
+
host: args.host,
|
|
45
|
+
image: args.image,
|
|
46
|
+
readOnly: args['read-only'] === true,
|
|
47
|
+
foreground: args.foreground === true,
|
|
48
|
+
force: args.force === true
|
|
49
|
+
}));
|
|
50
|
+
}
|
|
51
|
+
} catch (e) {
|
|
52
|
+
(0, _serverOutput.printError)(e);
|
|
53
|
+
return false;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
async function create(folderArg, opts) {
|
|
57
|
+
const cred = shareCredentials(opts.host);
|
|
58
|
+
await dk.dockerAvailable();
|
|
59
|
+
let folder;
|
|
60
|
+
let note;
|
|
61
|
+
try {
|
|
62
|
+
({
|
|
63
|
+
path: folder,
|
|
64
|
+
note
|
|
65
|
+
} = dk.hostFolder(folderArg));
|
|
66
|
+
} catch (e) {
|
|
67
|
+
throw e.code === 'ENOENT' ? new Error(`${folderArg} does not exist`) : e;
|
|
68
|
+
}
|
|
69
|
+
if (!_fs.default.statSync(folder).isDirectory()) throw new Error(`${folder} is not a folder`);
|
|
70
|
+
checkFolderSafety(folder, opts.force, _os.default.homedir(), process.platform, cred.privateKeySource && cred.privateKeySource !== 'GROK_PRIVATE_KEY' ? [cred.privateKeySource] : []);
|
|
71
|
+
if (note) color.warn(note);
|
|
72
|
+
const alias = cred.alias ?? cred.url;
|
|
73
|
+
const client = await (0, _serverClient.createClient)(alias);
|
|
74
|
+
const before = await client.get('/local_shares');
|
|
75
|
+
const created = await color.step(`Connecting ${folder} to ${cred.url}`, () => client.post('/local_shares', {
|
|
76
|
+
host: dk.canonicalHost(),
|
|
77
|
+
hostLabel: _os.default.hostname(),
|
|
78
|
+
folder: dk.canonicalFolder(folder),
|
|
79
|
+
folderLabel: folder,
|
|
80
|
+
readOnly: opts.readOnly
|
|
81
|
+
})).catch(e => {
|
|
82
|
+
throw e?.apiError?.errorCode === 403 && `${e.apiError.error}`.includes('ConnectLocalFolders') ? new Error(`You don't have permission to connect folders on ${new URL(cred.url).host}. Ask an administrator.`) : e;
|
|
83
|
+
});
|
|
84
|
+
const conn = created.connection;
|
|
85
|
+
const name = conn.friendlyName ?? conn.name;
|
|
86
|
+
if (!opts.image && !dk.isAgentImage(created.agentImage)) throw new Error(`${new URL(cred.url).host} asks to run the folder agent from ${created.agentImage}, not ${dk.AGENT_REPOSITORY}. ` + `Pass --image ${created.agentImage} to run it anyway.`);
|
|
87
|
+
const spec = {
|
|
88
|
+
image: opts.image ?? created.agentImage,
|
|
89
|
+
connectionId: conn.id,
|
|
90
|
+
alias,
|
|
91
|
+
apiRoot: cred.url,
|
|
92
|
+
name,
|
|
93
|
+
folder,
|
|
94
|
+
hostLabel: _os.default.hostname(),
|
|
95
|
+
readOnly: opts.readOnly,
|
|
96
|
+
envFile: '',
|
|
97
|
+
foreground: opts.foreground,
|
|
98
|
+
user: dk.linuxUser()
|
|
99
|
+
};
|
|
100
|
+
const containers = await dk.ps({
|
|
101
|
+
[`${dk.LABEL}.connection`]: conn.id,
|
|
102
|
+
[`${dk.LABEL}.url`]: cred.url
|
|
103
|
+
});
|
|
104
|
+
const existing = containers.find(c => c.name === dk.containerName(cred.url, conn.id));
|
|
105
|
+
const keys = await shareKeys(client, conn.id);
|
|
106
|
+
// A key is registered only once the image is here: a Ctrl+C during a long pull then leaves nothing behind.
|
|
107
|
+
const image = await color.step(`Getting the folder agent image ${spec.image}`, () => dk.ensureImage(spec.image));
|
|
108
|
+
if (image.pullError) color.warn(`Could not pull ${spec.image}, using the local copy: ${image.pullError}`);
|
|
109
|
+
spec.imageId = image.id;
|
|
110
|
+
if (existing && !opts.foreground && dk.sameSettings(existing, spec) && keys.some(k => k.id === existing.labels[`${dk.LABEL}.key`])) {
|
|
111
|
+
// A share deleted on the server leaves its agent idle (4410), so the recreated one needs a fresh start.
|
|
112
|
+
const wasDeleted = !before.some(s => s.id === conn.id);
|
|
113
|
+
if (existing.state === 'running' && !wasDeleted) color.info(`"${name}" is already connected from this machine (container ${existing.name})`);else await color.step(`Starting container ${existing.name}`, () => dk.dockerOk([wasDeleted ? 'restart' : 'start', existing.name]));
|
|
114
|
+
return await reportOnline(client, conn.id, name, existing.name);
|
|
115
|
+
}
|
|
116
|
+
for (const c of containers) await color.step(`Replacing container ${c.name}`, () => dk.rm(c.name));
|
|
117
|
+
const key = await issueShareKey(client, conn.id);
|
|
118
|
+
const revokeMine = () => client.del(`/users/keys/${key.id}`).catch(() => {});
|
|
119
|
+
spec.keyId = key.id;
|
|
120
|
+
const container = dk.containerName(cred.url, conn.id, opts.foreground);
|
|
121
|
+
if (opts.foreground) return await runForeground(client, spec, key.privateKey, container, name, revokeMine);
|
|
122
|
+
try {
|
|
123
|
+
await withKeyFile(key.privateKey, envFile => color.step('Starting the folder agent', () => dk.dockerOk(dk.runArgs({
|
|
124
|
+
...spec,
|
|
125
|
+
envFile
|
|
126
|
+
}))), revokeMine);
|
|
127
|
+
} catch (e) {
|
|
128
|
+
await revokeMine();
|
|
129
|
+
throw e;
|
|
130
|
+
}
|
|
131
|
+
if (await dk.isRunning(container)) await revokeShareKeys(client, conn.id, key.id);
|
|
132
|
+
if (process.platform === 'win32' || process.platform === 'darwin') color.warn('The folder comes back after a reboot only when Docker does: let Docker Desktop start when you sign in.');
|
|
133
|
+
return await reportOnline(client, conn.id, name, container);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** `docker create` reads the key file, which is deleted before the agent starts; `start -a` forwards Ctrl+C. */
|
|
137
|
+
async function runForeground(client, spec, key, container, name, revokeMine) {
|
|
138
|
+
try {
|
|
139
|
+
await withKeyFile(key, envFile => dk.dockerOk(['create', ...dk.runArgs({
|
|
140
|
+
...spec,
|
|
141
|
+
envFile
|
|
142
|
+
}).slice(1)]), revokeMine);
|
|
143
|
+
color.info(`Serving "${name}" until Ctrl+C`);
|
|
144
|
+
process.on('SIGINT', () => {
|
|
145
|
+
dk.docker(['stop', container]).catch(() => {});
|
|
146
|
+
});
|
|
147
|
+
const attached = dk.docker(['start', '-a', container], true);
|
|
148
|
+
if (await dk.isRunning(container, 10)) await revokeShareKeys(client, spec.connectionId, spec.keyId).catch(() => {});
|
|
149
|
+
const r = await attached;
|
|
150
|
+
return r.code === 0 || r.code === 130 || r.code === 143;
|
|
151
|
+
} finally {
|
|
152
|
+
await revokeMine();
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* [key], base64 in a 0600 `--env-file` that exists only while [action] runs; Ctrl+C meanwhile deletes it and
|
|
158
|
+
* runs [onAbort] before exiting.
|
|
159
|
+
*/
|
|
160
|
+
async function withKeyFile(key, action, onAbort = async () => {}) {
|
|
161
|
+
const dir = _fs.default.mkdtempSync(_path.default.join(_os.default.tmpdir(), 'grok-folder-'));
|
|
162
|
+
const file = _path.default.join(dir, 'key.env');
|
|
163
|
+
const abort = signal => {
|
|
164
|
+
_fs.default.rmSync(dir, {
|
|
165
|
+
recursive: true,
|
|
166
|
+
force: true
|
|
167
|
+
});
|
|
168
|
+
onAbort().finally(() => process.exit(signal === 'SIGINT' ? 130 : 143));
|
|
169
|
+
};
|
|
170
|
+
process.once('SIGINT', abort);
|
|
171
|
+
process.once('SIGTERM', abort);
|
|
172
|
+
try {
|
|
173
|
+
_fs.default.writeFileSync(file, `GROK_SHARE_KEY=${Buffer.from(JSON.stringify(key)).toString('base64')}\n`, {
|
|
174
|
+
mode: 0o600
|
|
175
|
+
});
|
|
176
|
+
return await action(file);
|
|
177
|
+
} finally {
|
|
178
|
+
process.off('SIGINT', abort);
|
|
179
|
+
process.off('SIGTERM', abort);
|
|
180
|
+
_fs.default.rmSync(dir, {
|
|
181
|
+
recursive: true,
|
|
182
|
+
force: true
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/** The container never gets the user's own key: the server issues each share one that can only serve it. */
|
|
188
|
+
async function issueShareKey(client, connectionId) {
|
|
189
|
+
const {
|
|
190
|
+
publicKey,
|
|
191
|
+
privateKey
|
|
192
|
+
} = (0, _keypair.generateKeyPair)();
|
|
193
|
+
const key = await client.post(`/local_shares/${connectionId}/agent_key`, {
|
|
194
|
+
publicKey: JSON.stringify(publicKey)
|
|
195
|
+
});
|
|
196
|
+
return {
|
|
197
|
+
id: key.id,
|
|
198
|
+
privateKey
|
|
199
|
+
};
|
|
200
|
+
}
|
|
201
|
+
const folderScope = connectionId => `connected-folder:${connectionId}`;
|
|
202
|
+
async function shareKeys(client, connectionId) {
|
|
203
|
+
return ((await client.get('/users/current/keys')) ?? []).filter(k => k.scope === folderScope(connectionId));
|
|
204
|
+
}
|
|
205
|
+
async function revokeShareKeys(client, connectionId, keep) {
|
|
206
|
+
for (const k of await shareKeys(client, connectionId)) {
|
|
207
|
+
if (k.id !== keep) await client.del(`/users/keys/${k.id}`);
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
async function reportOnline(client, id, name, container) {
|
|
211
|
+
const online = await color.step('Waiting for the agent to connect', async () => {
|
|
212
|
+
for (let i = 0; i < 30; i++) {
|
|
213
|
+
const s = (await client.get('/local_shares')).find(s => s.id === id);
|
|
214
|
+
if (s != null && s.isAvailable !== false) return true;
|
|
215
|
+
await new Promise(r => setTimeout(r, 1000));
|
|
216
|
+
}
|
|
217
|
+
return false;
|
|
218
|
+
});
|
|
219
|
+
if (!online) {
|
|
220
|
+
color.warn(`"${name}" is connected, but its agent has not dialed in yet. See: grok connect-folder logs ${id.slice(0, 8)}`);
|
|
221
|
+
return true;
|
|
222
|
+
}
|
|
223
|
+
color.success(`"${name}" is online (connection ${id}, container ${container})`);
|
|
224
|
+
return true;
|
|
225
|
+
}
|
|
226
|
+
async function list(host, output) {
|
|
227
|
+
const containers = await dockerContainersOrEmpty();
|
|
228
|
+
const own = (0, _keypair.getServerCredentials)(host ?? '');
|
|
229
|
+
const aliases = new Set([own.alias ?? own.url]);
|
|
230
|
+
if (host == null) {
|
|
231
|
+
for (const c of containers) aliases.add(c.labels[`${dk.LABEL}.alias`]);
|
|
232
|
+
}
|
|
233
|
+
const rows = [];
|
|
234
|
+
const seen = new Set();
|
|
235
|
+
for (const alias of aliases) {
|
|
236
|
+
let shares = null;
|
|
237
|
+
let keys = [];
|
|
238
|
+
let url;
|
|
239
|
+
try {
|
|
240
|
+
const cred = (0, _keypair.getServerCredentials)(alias);
|
|
241
|
+
url = cred.url;
|
|
242
|
+
const client = await (0, _serverClient.createClient)(alias);
|
|
243
|
+
shares = await client.get('/local_shares');
|
|
244
|
+
keys = (await client.get('/users/current/keys')) ?? [];
|
|
245
|
+
} catch (e) {
|
|
246
|
+
color.warn(`${alias}: ${e.message ?? e}`);
|
|
247
|
+
url = '';
|
|
248
|
+
}
|
|
249
|
+
const ofServer = containers.filter(c => c.labels[`${dk.LABEL}.url`] === url || c.labels[`${dk.LABEL}.alias`] === alias);
|
|
250
|
+
for (const s of shares ?? []) {
|
|
251
|
+
const c = ofServer.find(c => c.labels[`${dk.LABEL}.connection`] === s.id);
|
|
252
|
+
seen.add(`${alias}|${s.id}`);
|
|
253
|
+
// The server refuses an agent whose key is not limited to its folder: its container runs, but serves nothing.
|
|
254
|
+
const outdated = c != null && !keys.some(k => k.id === c.labels[`${dk.LABEL}.key`] && k.scope === folderScope(s.id));
|
|
255
|
+
rows.push(row(s.friendlyName ?? s.name, s.parameters?.localShareFolder, s.parameters?.localShareReadOnly === 'true', c?.state, outdated ? 'outdated key: re-run connect-folder' : s.isAvailable === false ? 'offline' : 'online', s.parameters?.localShareHost, s.id, alias));
|
|
256
|
+
}
|
|
257
|
+
for (const c of ofServer) {
|
|
258
|
+
const id = c.labels[`${dk.LABEL}.connection`];
|
|
259
|
+
if (seen.has(`${alias}|${id}`)) continue;
|
|
260
|
+
seen.add(`${alias}|${id}`);
|
|
261
|
+
rows.push(row(c.labels[`${dk.LABEL}.name`], c.labels[`${dk.LABEL}.folder`], c.labels[`${dk.LABEL}.readonly`] === 'true', c.state, shares == null ? 'unknown' : 'revoked', _os.default.hostname(), id, alias));
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
(0, _serverOutput.printOutput)(rows, output);
|
|
265
|
+
return true;
|
|
266
|
+
}
|
|
267
|
+
function row(name, folder, readOnly, container, server, host, id, alias) {
|
|
268
|
+
return {
|
|
269
|
+
name,
|
|
270
|
+
folder,
|
|
271
|
+
access: readOnly ? 'read-only' : 'read-write',
|
|
272
|
+
container: container ?? '-',
|
|
273
|
+
server,
|
|
274
|
+
host,
|
|
275
|
+
id,
|
|
276
|
+
alias
|
|
277
|
+
};
|
|
278
|
+
}
|
|
279
|
+
async function dockerContainersOrEmpty() {
|
|
280
|
+
try {
|
|
281
|
+
return await dk.ps();
|
|
282
|
+
} catch (e) {
|
|
283
|
+
color.warn(`${e.message ?? e} Only the server side is listed.`);
|
|
284
|
+
return [];
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
async function manage(command, ref, host, follow) {
|
|
288
|
+
await dk.dockerAvailable();
|
|
289
|
+
const c = findContainer(inScope(await dk.ps(), host), ref);
|
|
290
|
+
if (command === 'logs') return (await dk.logs(c.name, follow)).code === 0;
|
|
291
|
+
const name = c.labels[`${dk.LABEL}.name`];
|
|
292
|
+
await color.step(`${command === 'start' ? 'Starting' : 'Stopping'} "${name}"`, () => command === 'start' ? dk.start(c.name) : dk.stop(c.name));
|
|
293
|
+
if (command === 'stop') return true;
|
|
294
|
+
let client;
|
|
295
|
+
try {
|
|
296
|
+
client = await (0, _serverClient.createClient)(c.labels[`${dk.LABEL}.alias`]);
|
|
297
|
+
} catch (e) {
|
|
298
|
+
color.warn(`"${name}" is started, but ${c.labels[`${dk.LABEL}.url`]} cannot be reached to check it: ${e.message ?? e}`);
|
|
299
|
+
return true;
|
|
300
|
+
}
|
|
301
|
+
return await reportOnline(client, c.labels[`${dk.LABEL}.connection`], name, c.name);
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/** The containers of the server [host] names (all of them without one), by alias or by URL. */
|
|
305
|
+
function inScope(containers, host) {
|
|
306
|
+
if (host == null) return containers;
|
|
307
|
+
let url;
|
|
308
|
+
try {
|
|
309
|
+
url = (0, _keypair.getServerCredentials)(host).url;
|
|
310
|
+
} catch {/* an alias that is gone can still name its containers */}
|
|
311
|
+
return containers.filter(c => c.labels[`${dk.LABEL}.alias`] === host || c.labels[`${dk.LABEL}.url`] === url);
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/**
|
|
315
|
+
* Removes the share's containers on this machine, then its connection and keys on the server. The containers go
|
|
316
|
+
* even when the server cannot be reached.
|
|
317
|
+
*/
|
|
318
|
+
async function remove(ref, host) {
|
|
319
|
+
const containers = await dockerContainersOrEmpty();
|
|
320
|
+
const found = matchContainers(inScope(containers, host), ref);
|
|
321
|
+
const ids = [...new Set(found.map(c => c.labels[`${dk.LABEL}.connection`]))];
|
|
322
|
+
if (new Set(found.map(c => `${c.labels[`${dk.LABEL}.alias`]}|${c.labels[`${dk.LABEL}.connection`]}`)).size > 1) throw ambiguous(ref, found.map(c => `${c.labels[`${dk.LABEL}.name`]} (${c.labels[`${dk.LABEL}.alias`]})`));
|
|
323
|
+
const alias = host ?? found[0]?.labels[`${dk.LABEL}.alias`] ?? '';
|
|
324
|
+
let id = ids[0];
|
|
325
|
+
let client;
|
|
326
|
+
let serverError;
|
|
327
|
+
try {
|
|
328
|
+
client = await (0, _serverClient.createClient)(alias);
|
|
329
|
+
if (id == null) {
|
|
330
|
+
const shares = (await client.get('/local_shares')).filter(s => matches(ref, s.id, s.friendlyName ?? s.name, s.parameters?.localShareFolder));
|
|
331
|
+
if (shares.length > 1) throw ambiguous(ref, shares.map(s => s.friendlyName ?? s.name));
|
|
332
|
+
id = shares[0]?.id;
|
|
333
|
+
}
|
|
334
|
+
} catch (e) {
|
|
335
|
+
serverError = new Error(`${alias || 'the default server'}: ${e?.message ?? e}`);
|
|
336
|
+
}
|
|
337
|
+
if (id == null) throw serverError ?? new Error(`No connected folder matches "${ref}". See: grok connect-folder list`);
|
|
338
|
+
for (const c of inScope(containers, alias).filter(c => c.labels[`${dk.LABEL}.connection`] === id)) await color.step(`Removing container ${c.name}`, () => dk.rm(c.name));
|
|
339
|
+
if (client && !serverError) {
|
|
340
|
+
let doing = 'revoke the folder key';
|
|
341
|
+
try {
|
|
342
|
+
await color.step('Revoking the folder key', () => revokeShareKeys(client, id));
|
|
343
|
+
doing = 'delete the connection';
|
|
344
|
+
await color.step(`Deleting connection ${id}`, () => new _nodeDapi.NodeDapi(client).connections.delete({
|
|
345
|
+
id: id
|
|
346
|
+
}));
|
|
347
|
+
} catch (e) {
|
|
348
|
+
serverError = new Error(`could not ${doing}: ${e?.message ?? e}`);
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
if (serverError) {
|
|
352
|
+
color.error(`Removed on this machine, but the connection is still on the server: ${serverError.message ?? serverError}. ` + `Run grok connect-folder rm ${id.slice(0, 8)} again, or delete the connection in Datagrok`);
|
|
353
|
+
return false;
|
|
354
|
+
}
|
|
355
|
+
return true;
|
|
356
|
+
}
|
|
357
|
+
function findContainer(containers, ref) {
|
|
358
|
+
const found = matchContainers(containers, ref);
|
|
359
|
+
if (found.length === 0) throw new Error(`No folder container on this machine matches "${ref}". See: grok connect-folder list`);
|
|
360
|
+
if (found.length > 1) throw ambiguous(ref, found.map(c => `${c.labels[`${dk.LABEL}.name`]} (${c.labels[`${dk.LABEL}.alias`]})`));
|
|
361
|
+
return found[0];
|
|
362
|
+
}
|
|
363
|
+
function matchContainers(containers, ref) {
|
|
364
|
+
return containers.filter(c => matches(ref, c.labels[`${dk.LABEL}.connection`], c.labels[`${dk.LABEL}.name`], c.labels[`${dk.LABEL}.folder`]));
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
/** A share's `<ref>`: its name, a connection id prefix of 8 or more characters, or its folder. */
|
|
368
|
+
function matches(ref, id, name, folder) {
|
|
369
|
+
if (name != null && name.toLowerCase() === ref.toLowerCase()) return true;
|
|
370
|
+
if (ref.length >= 8 && id?.toLowerCase().startsWith(ref.toLowerCase())) return true;
|
|
371
|
+
if (folder == null) return false;
|
|
372
|
+
let abs = _path.default.resolve(ref);
|
|
373
|
+
try {
|
|
374
|
+
abs = _fs.default.realpathSync.native(abs);
|
|
375
|
+
} catch {/* a folder that is gone can still name its share */}
|
|
376
|
+
return dk.canonicalFolder(abs) === dk.canonicalFolder(folder);
|
|
377
|
+
}
|
|
378
|
+
function ambiguous(ref, names) {
|
|
379
|
+
return new Error(`"${ref}" matches several connected folders: ${names.join(', ')}. Use the connection id, or --host`);
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
/** The server alias with the keypair `grok connect-folder` signs in with (the agent gets a key of its own). */
|
|
383
|
+
function shareCredentials(host) {
|
|
384
|
+
const cred = (0, _keypair.getServerCredentials)(host ?? '');
|
|
385
|
+
if (cred.privateKey == null) throw new Error(`grok connect-folder signs in with a keypair, and ${cred.alias ?? cred.url} has none: run grok login ${cred.url}`);
|
|
386
|
+
return cred;
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
/**
|
|
390
|
+
* Refuses a folder that is, holds or is inside `~/.grok` or one of [keyFiles] (private keys); a filesystem or
|
|
391
|
+
* drive root, or the home folder, needs [force].
|
|
392
|
+
*/
|
|
393
|
+
function checkFolderSafety(folder, force, home = _os.default.homedir(), platform = process.platform, keyFiles = []) {
|
|
394
|
+
const f = dk.canonicalFolder(folder, platform);
|
|
395
|
+
const within = (child, parent) => child === parent || child.startsWith(parent.endsWith('/') ? parent : `${parent}/`);
|
|
396
|
+
for (const secret of [_path.default.join(home, '.grok'), ...keyFiles]) {
|
|
397
|
+
const s = dk.canonicalFolder(secret, platform);
|
|
398
|
+
if (within(f, s) || within(s, f)) throw new Error(`${folder} is or holds ${secret}, where your private keys are; it cannot be connected`);
|
|
399
|
+
}
|
|
400
|
+
if (!force && (f === '/' || /^[a-z]:\/?$/i.test(f) || f === dk.canonicalFolder(home, platform))) throw new Error(`${folder} is a root or your home folder; pass --force to connect it anyway`);
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
/** minimist reads `-f <connection>` as a value of the flag, so the value goes back to the positionals. */
|
|
404
|
+
function flag(args, name) {
|
|
405
|
+
const v = args[name];
|
|
406
|
+
if (typeof v === 'string' && v !== '') {
|
|
407
|
+
args._.push(v);
|
|
408
|
+
return true;
|
|
409
|
+
}
|
|
410
|
+
return v === true;
|
|
411
|
+
}
|
package/bin/commands/help.js
CHANGED
|
@@ -31,6 +31,8 @@ Commands:
|
|
|
31
31
|
testall Run packages tests
|
|
32
32
|
migrate Migrate legacy tags to meta.role
|
|
33
33
|
server (s) Manage a Datagrok server (list/get/delete entities, run functions)
|
|
34
|
+
src Work across the repos of an umbrella checkout (git submodules)
|
|
35
|
+
connect-folder Connect a local folder to Datagrok as a connection (runs a Docker container)
|
|
34
36
|
|
|
35
37
|
To get help on a particular command, use:
|
|
36
38
|
grok <command> --help
|
|
@@ -96,7 +98,7 @@ grok add tests
|
|
|
96
98
|
Please note that entity names may only include letters and numbers
|
|
97
99
|
|
|
98
100
|
--domain scaffolds a working browse/CRUD app over an entity-mapped domain table
|
|
99
|
-
(\`grok.dapi.domains\`) from the
|
|
101
|
+
(\`grok.dapi.domains\`) from the EMS defaults (\`@datagrok-libraries/ems\`): a three-line
|
|
100
102
|
app function, a \`src/app.spec.json\` the designer edits, and — for a schema the package
|
|
101
103
|
does not declare yet — a one-table starter \`databases/<schema>/schema.json\`. Give it
|
|
102
104
|
one table (\`--domain tracker.issue\`), a whole schema the package declares in
|
|
@@ -140,7 +142,7 @@ Options:
|
|
|
140
142
|
--verbose Print detailed output
|
|
141
143
|
--ui Also generate \`src/generated/db-ui.ts\` — typed u2 handles
|
|
142
144
|
(\`get<Schema>Db()\` → a \`DomainTable<Row>\` per table, opened in
|
|
143
|
-
parallel behind one await) over \`@datagrok-libraries/
|
|
145
|
+
parallel behind one await) over \`@datagrok-libraries/ems\`. Once the
|
|
144
146
|
file exists, plain \`grok api\` keeps it up to date; delete it to
|
|
145
147
|
opt out again
|
|
146
148
|
`;
|
|
@@ -167,6 +169,36 @@ Options:
|
|
|
167
169
|
For CI, set GROK_PRIVATE_KEY to the private key JWK (raw or base64) instead of a
|
|
168
170
|
config file. Read more: https://datagrok.ai/help/govern/access-control/keypair-authentication
|
|
169
171
|
`;
|
|
172
|
+
const HELP_CONNECT_FOLDER = `
|
|
173
|
+
Usage: grok connect-folder <folder> [--host <alias>] [--read-only] [--foreground] [--image <ref>] [--force]
|
|
174
|
+
grok connect-folder list [--host <alias>] [--output table|json|csv]
|
|
175
|
+
grok connect-folder start|stop|logs <connection> [-f]
|
|
176
|
+
grok connect-folder rm <connection> [--host <alias>]
|
|
177
|
+
|
|
178
|
+
Connect a local folder to Datagrok as a connection. The folder agent runs in
|
|
179
|
+
Docker, survives closing the terminal, and dials out to the server: no inbound
|
|
180
|
+
port is opened. Connecting the same folder again resumes the same connection.
|
|
181
|
+
|
|
182
|
+
grok connect-folder ~/data Read-write, on the default server
|
|
183
|
+
grok connect-folder D:\\data --read-only Others with access can only read
|
|
184
|
+
grok connect-folder list Your connected folders and their containers
|
|
185
|
+
grok connect-folder stop "laptop data" Stop serving; stays stopped after a reboot
|
|
186
|
+
grok connect-folder rm 7ff0714e Remove the container and delete the connection
|
|
187
|
+
|
|
188
|
+
<connection> is the connection name, a connection id prefix (8+ characters), or
|
|
189
|
+
the folder.
|
|
190
|
+
|
|
191
|
+
Options:
|
|
192
|
+
--host Server alias from ~/.grok/config.yaml (default: the default server)
|
|
193
|
+
--read-only Mount the folder read-only; writes are refused
|
|
194
|
+
--foreground Run attached, removed on Ctrl+C (CI, debugging)
|
|
195
|
+
--image Agent image (default: the one the server names)
|
|
196
|
+
--force Allow a drive root or the home folder
|
|
197
|
+
-f With logs: follow
|
|
198
|
+
|
|
199
|
+
Requires Docker and a keypair (grok login <server>), which never leaves this
|
|
200
|
+
machine: the container holds a key of its own that can only serve this folder.
|
|
201
|
+
`;
|
|
170
202
|
const HELP_CONFIG = `
|
|
171
203
|
Usage: grok config
|
|
172
204
|
|
|
@@ -469,6 +501,38 @@ Examples:
|
|
|
469
501
|
|
|
470
502
|
The instance name must match a server alias in ~/.grok/config.yaml.
|
|
471
503
|
`;
|
|
504
|
+
const HELP_SRC = `
|
|
505
|
+
Usage: grok src <command> [options]
|
|
506
|
+
|
|
507
|
+
Git recipes across the repos of the datagrok umbrella (git submodules). Runs from anywhere
|
|
508
|
+
in the tree; paths are relative to the umbrella root. Every git command that changes
|
|
509
|
+
something is printed; a repo that fails is listed and the exit code is 1.
|
|
510
|
+
|
|
511
|
+
Commands:
|
|
512
|
+
clone [profile | path...] Clone the umbrella and the given children (profiles: .gitprofiles;
|
|
513
|
+
none: profile "default", else every child)
|
|
514
|
+
add <profile | path>... Clone more children, each on its branch
|
|
515
|
+
sparse <path> <glob>... Clone a child with only the matching files
|
|
516
|
+
switch Put every child on its branch from .gitmodules
|
|
517
|
+
status Branch, unpushed commits and changes of every repo
|
|
518
|
+
pull git pull --ff-only in every repo
|
|
519
|
+
at <ref> Check out an umbrella ref with its pinned children
|
|
520
|
+
bump [path...] Pin children to their branch tip on origin and move clean checkouts
|
|
521
|
+
there; commit in each umbrella (never pushes)
|
|
522
|
+
wt add <name> <path>... Worktrees of the umbrella and the given children on branch <name>
|
|
523
|
+
doctor [--fix] Detached HEADs, unpushed commits, stale URLs; --fix resets those URLs
|
|
524
|
+
|
|
525
|
+
Options:
|
|
526
|
+
--explain Print the git commands without running them
|
|
527
|
+
--url <url> clone: the umbrella (default git@github.com:datagrok-ai/datagrok.git)
|
|
528
|
+
--dir <dir> clone: target directory; wt add: worktree root (default <umbrella>-worktrees/<name>)
|
|
529
|
+
|
|
530
|
+
Examples:
|
|
531
|
+
grok src clone platform
|
|
532
|
+
grok src sparse help '*.md'
|
|
533
|
+
grok src bump core public
|
|
534
|
+
grok src wt add fix-login core public
|
|
535
|
+
`;
|
|
472
536
|
const help = exports.help = {
|
|
473
537
|
add: HELP_ADD,
|
|
474
538
|
api: HELP_API,
|
|
@@ -491,5 +555,7 @@ const help = exports.help = {
|
|
|
491
555
|
server: _server.HELP_SERVER,
|
|
492
556
|
s: _server.HELP_SERVER,
|
|
493
557
|
setup: HELP_SETUP,
|
|
558
|
+
src: HELP_SRC,
|
|
559
|
+
'connect-folder': HELP_CONNECT_FOLDER,
|
|
494
560
|
help: HELP
|
|
495
561
|
};
|
package/bin/commands/publish.js
CHANGED
|
@@ -196,6 +196,12 @@ function dockerBuild(imageName, dockerfilePath, context, usePlatform = true) {
|
|
|
196
196
|
});
|
|
197
197
|
return true;
|
|
198
198
|
} catch (e) {
|
|
199
|
+
// A local build targets the host platform, which fails on Apple Silicon for a base
|
|
200
|
+
// published only for linux/amd64 (datagrok/demo_db_*).
|
|
201
|
+
if (!usePlatform && process.arch !== 'x64') {
|
|
202
|
+
color.warn(`Building ${imageName} for the host platform failed. Retrying for linux/amd64...`);
|
|
203
|
+
return dockerBuild(imageName, dockerfilePath, context, true);
|
|
204
|
+
}
|
|
199
205
|
color.error(`Failed to build ${imageName}: ${e.message || e}`);
|
|
200
206
|
return false;
|
|
201
207
|
}
|
|
@@ -21,7 +21,9 @@ function _interopRequireWildcard(e, t) { if ("function" == typeof WeakMap) var r
|
|
|
21
21
|
/// `grok s domains ...` — entity-mapped domain schemas and their rows (DomainsRouter, `/domains/`).
|
|
22
22
|
/// Every verb addresses a schema as `<schema>` or a table as `<schema>.<table>`.
|
|
23
23
|
|
|
24
|
-
const APPLY_KEYS = ['tables', 'extend', 'propertySchemas', 'dropTables', 'ifVersion', 'confirmDestructive'];
|
|
24
|
+
const APPLY_KEYS = ['tables', 'extend', 'propertySchemas', 'dropTables', 'access', 'ifVersion', 'ifIncarnation', 'confirmDestructive'];
|
|
25
|
+
/** Accepted by a user-managed schema's apply only; a package schema refuses them as unknown keys. */
|
|
26
|
+
const USER_APPLY_KEYS = ['friendlyName', 'description', 'storage'];
|
|
25
27
|
const PERMISSIONS = ['View', 'Edit', 'Delete', 'Share', 'Extend'];
|
|
26
28
|
const USAGE = `Usage: grok s domains <verb> [args]
|
|
27
29
|
list [<schema>] [--filter <text>] Schemas, or the tables of one schema
|
|
@@ -210,11 +212,23 @@ async function handleDomains(dapi, verb, rest, argv, output) {
|
|
|
210
212
|
}
|
|
211
213
|
case 'apply':
|
|
212
214
|
{
|
|
213
|
-
if (!args[0] || !argv.json) return usage('apply <schema> --json manifest.json [--dry-run] [--confirm-destructive] [--if-version <v
|
|
215
|
+
if (!args[0] || !argv.json) return usage('apply <schema> --json manifest.json [--dry-run] [--confirm-destructive] [--if-version <v>|current]');
|
|
214
216
|
const a = (0, _nodeDapi.parseDomainAddress)(args[0], {
|
|
215
217
|
table: false
|
|
216
218
|
});
|
|
217
|
-
const
|
|
219
|
+
const json = readJson(argv.json);
|
|
220
|
+
// One registry read, only when the token or the metadata keys depend on who manages the schema.
|
|
221
|
+
const s = argv['if-version'] === undefined || argv['if-version'] === 'current' || USER_APPLY_KEYS.some(k => json?.[k] !== undefined) ? await domains.schema(a.schema) : null;
|
|
222
|
+
const body = applyBody(json, argv, {
|
|
223
|
+
userManaged: s?.managedBy === 'user'
|
|
224
|
+
});
|
|
225
|
+
if (body.ifVersion === 'current') {
|
|
226
|
+
// Both halves of the token as registered now: a name deleted and re-created
|
|
227
|
+
// since the file was written must conflict, not pass as a fresh `1`.
|
|
228
|
+
const m = await domains.manifest(a.schema);
|
|
229
|
+
body.ifVersion = String(m?.version ?? s.version ?? '1');
|
|
230
|
+
if (m?.incarnation !== undefined) body.ifIncarnation = String(m.incarnation);
|
|
231
|
+
} else if (body.ifVersion === undefined && s.managedBy === 'user') return usage(`apply ${a.schema} --json manifest.json --if-version <v>|current — a user-managed schema ` + 'requires the version the edit was made against (the "version" of the manifest JSON is used when present)');
|
|
218
232
|
printJson(await domains.applySchema(a.schema, body, argv['dry-run'] === true), output, r => r?.version);
|
|
219
233
|
return true;
|
|
220
234
|
}
|
|
@@ -305,12 +319,15 @@ function aggregateSpec(argv, filter, limit) {
|
|
|
305
319
|
return spec;
|
|
306
320
|
}
|
|
307
321
|
|
|
308
|
-
/** The apply payload from a schema.json (or a partial body): only the keys the server accepts
|
|
309
|
-
|
|
322
|
+
/** The apply payload from a schema.json (or a partial body): only the keys the server accepts
|
|
323
|
+
* (the schema's metadata and storage keys for a user-managed schema only), flags folded in. */
|
|
324
|
+
function applyBody(json, argv, options = {}) {
|
|
310
325
|
const body = {};
|
|
311
|
-
for (const k of APPLY_KEYS) if (json?.[k] !== undefined) body[k] = json[k];
|
|
326
|
+
for (const k of options.userManaged ? [...APPLY_KEYS, ...USER_APPLY_KEYS] : APPLY_KEYS) if (json?.[k] !== undefined) body[k] = json[k];
|
|
312
327
|
if (argv['confirm-destructive'] === true) body.confirmDestructive = true;
|
|
313
|
-
if (argv['if-version'] !== undefined) body.ifVersion = String(argv['if-version']);
|
|
328
|
+
if (argv['if-version'] !== undefined) body.ifVersion = String(argv['if-version']);else if (json?.version !== undefined) body.ifVersion = String(json.version);
|
|
329
|
+
// The manifest's `incarnation` rides with its `version` (the server keeps it optional).
|
|
330
|
+
if (json?.incarnation !== undefined) body.ifIncarnation = String(json.incarnation);
|
|
314
331
|
return body;
|
|
315
332
|
}
|
|
316
333
|
|