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.
@@ -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
+ }
@@ -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 u2 defaults (\`@datagrok-libraries/u2\`): a three-line
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/u2\`. Once the
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
  };
@@ -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 body = applyBody(readJson(argv.json), argv);
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, flags folded in. */
309
- function applyBody(json, argv) {
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