datagrok-tools 6.6.0 → 6.7.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/.devcontainer/entrypoint.sh +0 -0
- package/.turbo/turbo-build.log +5 -0
- package/CHANGELOG.md +11 -1
- package/CLAUDE.md +5 -6
- package/GROK_S.md +10 -2
- package/LICENSE.md +16 -0
- package/README.md +5 -3
- package/bin/commands/build.js +123 -139
- package/bin/commands/check.js +3 -4
- package/bin/commands/config.js +4 -2
- package/bin/commands/create.js +37 -11
- package/bin/commands/help.js +85 -17
- package/bin/commands/link.js +10 -0
- package/bin/commands/login.js +213 -0
- package/bin/commands/publish.js +26 -23
- package/bin/commands/server.js +7 -1
- package/bin/commands/setup.js +139 -0
- package/bin/commands/stress-tests.js +3 -1
- package/bin/commands/tsc.js +26 -0
- package/bin/grok.js +34 -20
- package/bin/utils/color-utils.js +30 -1
- package/bin/utils/dev-key.js +45 -5
- package/bin/utils/keypair.js +292 -0
- package/bin/utils/node-dapi.js +20 -6
- package/bin/utils/playwright-runner.js +9 -3
- package/bin/utils/server-client.js +6 -7
- package/bin/utils/test-utils.js +19 -1
- package/bin/utils/toolchain.js +121 -0
- package/bin/utils/utils.js +9 -0
- package/package-template/.vscode/tasks.json +1 -1
- package/package-template/package.json +9 -14
- package/package-template/tsconfig.json +2 -69
- package/package.json +17 -17
- package/plugins/func-gen-plugin.js +9 -4
- package/turbo.json +9 -0
- package/package-template/.eslintrc.json +0 -38
- package/package-template/ts.webpack.config.js +0 -46
- package/package-template/webpack.config.js +0 -35
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
var _interopRequireDefault = require("@babel/runtime/helpers/interopRequireDefault");
|
|
4
|
+
Object.defineProperty(exports, "__esModule", {
|
|
5
|
+
value: true
|
|
6
|
+
});
|
|
7
|
+
exports.setup = setup;
|
|
8
|
+
var _fs = _interopRequireDefault(require("fs"));
|
|
9
|
+
var _path = _interopRequireDefault(require("path"));
|
|
10
|
+
var _child_process = require("child_process");
|
|
11
|
+
var color = _interopRequireWildcard(require("../utils/color-utils"));
|
|
12
|
+
var _build = require("./build");
|
|
13
|
+
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); }
|
|
14
|
+
/**
|
|
15
|
+
* `grok setup`: one command to get (or keep) a public/ checkout ready to build.
|
|
16
|
+
* 1. Node 20+ (22 recommended); pnpm through corepack, at the version package.json pins.
|
|
17
|
+
* 2. Removes per-package node_modules left by the npm era and stray package-lock.json files.
|
|
18
|
+
* 3. `pnpm install` at the workspace root.
|
|
19
|
+
* 4. Reports a global `grok` older than the workspace one (`--global` updates it).
|
|
20
|
+
* `--check` only reports.
|
|
21
|
+
*/
|
|
22
|
+
async function setup(args) {
|
|
23
|
+
const root = (0, _build.findWorkspaceRoot)(process.cwd());
|
|
24
|
+
if (!root) {
|
|
25
|
+
color.error('Not inside the public/ workspace (no pnpm-workspace.yaml above the current directory).');
|
|
26
|
+
return false;
|
|
27
|
+
}
|
|
28
|
+
const check = !!args.check;
|
|
29
|
+
const rootPkg = JSON.parse(_fs.default.readFileSync(_path.default.join(root, 'package.json'), 'utf8'));
|
|
30
|
+
const pinnedPnpm = (rootPkg.packageManager || 'pnpm@10').split('@')[1];
|
|
31
|
+
let ok = true;
|
|
32
|
+
|
|
33
|
+
// 1. Node and pnpm
|
|
34
|
+
const nodeMajor = parseInt(process.versions.node.split('.')[0], 10);
|
|
35
|
+
if (nodeMajor < 20) {
|
|
36
|
+
color.error(`Node ${process.versions.node}: 20 or later is required (22 recommended).`);
|
|
37
|
+
return false;
|
|
38
|
+
}
|
|
39
|
+
color.info(`Node ${process.versions.node}`);
|
|
40
|
+
let pnpmVersion = run('pnpm', ['--version']);
|
|
41
|
+
if (pnpmVersion !== pinnedPnpm) {
|
|
42
|
+
color.warn(`pnpm ${pnpmVersion || 'not found'}; the workspace pins ${pinnedPnpm}.`);
|
|
43
|
+
if (!check) {
|
|
44
|
+
if (run('corepack', ['--version']) === null) color.warn('corepack not found: `npm install -g corepack`, then rerun `grok setup`.');else {
|
|
45
|
+
(0, _child_process.spawnSync)('corepack', ['enable'], {
|
|
46
|
+
stdio: 'inherit',
|
|
47
|
+
shell: true
|
|
48
|
+
});
|
|
49
|
+
(0, _child_process.spawnSync)('corepack', ['prepare', `pnpm@${pinnedPnpm}`, '--activate'], {
|
|
50
|
+
stdio: 'inherit',
|
|
51
|
+
shell: true
|
|
52
|
+
});
|
|
53
|
+
pnpmVersion = run('pnpm', ['--version']);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
if (pnpmVersion === pinnedPnpm) color.info(`pnpm ${pnpmVersion}`);else ok = false;
|
|
58
|
+
|
|
59
|
+
// 2. leftovers from the npm era
|
|
60
|
+
const projectDirs = ['js-api', 'tools', 'build-config'].map(d => _path.default.join(root, d));
|
|
61
|
+
for (const group of ['packages', 'libraries']) for (const d of _fs.default.readdirSync(_path.default.join(root, group))) projectDirs.push(_path.default.join(root, group, d));
|
|
62
|
+
const staleModules = projectDirs.filter(d => isLegacyNodeModules(_path.default.join(d, 'node_modules')));
|
|
63
|
+
const staleLocks = projectDirs.map(d => _path.default.join(d, 'package-lock.json')).filter(f => _fs.default.existsSync(f));
|
|
64
|
+
if (staleModules.length || staleLocks.length) {
|
|
65
|
+
color.warn(`${staleModules.length} per-package node_modules from npm and ${staleLocks.length} package-lock.json file(s)` + (check ? ' would be removed' : ': removing'));
|
|
66
|
+
if (!check) {
|
|
67
|
+
for (const d of staleModules) _fs.default.rmSync(_path.default.join(d, 'node_modules'), {
|
|
68
|
+
recursive: true,
|
|
69
|
+
force: true
|
|
70
|
+
});
|
|
71
|
+
for (const f of staleLocks) _fs.default.rmSync(f, {
|
|
72
|
+
force: true
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
} else color.info('no npm-era leftovers');
|
|
76
|
+
|
|
77
|
+
// 3. install
|
|
78
|
+
if (!check && pnpmVersion === pinnedPnpm) {
|
|
79
|
+
color.log('pnpm install ...');
|
|
80
|
+
const r = (0, _child_process.spawnSync)('pnpm', ['install', '--frozen-lockfile'], {
|
|
81
|
+
stdio: 'inherit',
|
|
82
|
+
cwd: root,
|
|
83
|
+
shell: true
|
|
84
|
+
});
|
|
85
|
+
if (r.status !== 0) {
|
|
86
|
+
color.warn('frozen install failed (lockfile out of date?): retrying without --frozen-lockfile');
|
|
87
|
+
if ((0, _child_process.spawnSync)('pnpm', ['install'], {
|
|
88
|
+
stdio: 'inherit',
|
|
89
|
+
cwd: root,
|
|
90
|
+
shell: true
|
|
91
|
+
}).status !== 0) {
|
|
92
|
+
color.error('pnpm install failed');
|
|
93
|
+
return false;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// 4. the global grok
|
|
99
|
+
const workspaceGrok = JSON.parse(_fs.default.readFileSync(_path.default.join(root, 'tools', 'package.json'), 'utf8')).version;
|
|
100
|
+
// datagrok-tools before 6.6 has no --version and prints its usage instead
|
|
101
|
+
const raw = run('grok', ['--version']);
|
|
102
|
+
const globalGrok = raw === null ? null : /^\d+\.\d+\.\d+/.test(raw) ? raw : 'older than 6.6';
|
|
103
|
+
if (globalGrok && globalGrok !== workspaceGrok) {
|
|
104
|
+
color.warn(`global grok ${globalGrok}; the workspace has ${workspaceGrok}` + (args.global && !check ? ': updating' : ' (run `npm install -g datagrok-tools@' + workspaceGrok + '`, or `grok setup --global`)'));
|
|
105
|
+
if (args.global && !check) (0, _child_process.spawnSync)('npm', ['install', '-g', `datagrok-tools@${workspaceGrok}`], {
|
|
106
|
+
stdio: 'inherit',
|
|
107
|
+
shell: true
|
|
108
|
+
});
|
|
109
|
+
} else if (globalGrok) color.info(`grok ${globalGrok}`);
|
|
110
|
+
if (ok && !check) color.success('Ready: `grok build` in any package, `grok build --all` for everything.');
|
|
111
|
+
return ok;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** npm's node_modules holds real directories; pnpm's per-package one holds only symlinks (and .bin). */
|
|
115
|
+
function isLegacyNodeModules(dir) {
|
|
116
|
+
if (!_fs.default.existsSync(dir)) return false;
|
|
117
|
+
for (const e of _fs.default.readdirSync(dir, {
|
|
118
|
+
withFileTypes: true
|
|
119
|
+
})) {
|
|
120
|
+
if (e.name === '.bin' || e.name === '.cache' || e.name.startsWith('.')) continue;
|
|
121
|
+
const p = _path.default.join(dir, e.name);
|
|
122
|
+
if (e.isSymbolicLink()) continue;
|
|
123
|
+
if (e.isDirectory() && e.name.startsWith('@')) {
|
|
124
|
+
if (_fs.default.readdirSync(p, {
|
|
125
|
+
withFileTypes: true
|
|
126
|
+
}).some(s => s.isDirectory() && !s.isSymbolicLink())) return true;
|
|
127
|
+
continue;
|
|
128
|
+
}
|
|
129
|
+
if (e.isDirectory()) return true;
|
|
130
|
+
}
|
|
131
|
+
return false;
|
|
132
|
+
}
|
|
133
|
+
function run(cmd, args) {
|
|
134
|
+
const r = (0, _child_process.spawnSync)(cmd, args, {
|
|
135
|
+
encoding: 'utf8',
|
|
136
|
+
shell: true
|
|
137
|
+
});
|
|
138
|
+
return r.status === 0 ? r.stdout.trim().split(/\r?\n/).pop() || '' : null;
|
|
139
|
+
}
|
|
@@ -49,7 +49,9 @@ async function run(config, args) {
|
|
|
49
49
|
processArgs.push('./node-test-loader/register.mjs');
|
|
50
50
|
processArgs.push('src/package-test-node.ts');
|
|
51
51
|
processArgs.push(`--apiUrl=${config.url}`);
|
|
52
|
-
|
|
52
|
+
// A session token rather than the credential: it is short-lived, and with a keypair
|
|
53
|
+
// there is no reusable secret to put on a command line at all.
|
|
54
|
+
processArgs.push(`--token=${await testUtils.getToken(config.url, config.key)}`);
|
|
53
55
|
// Explicit even though it's the runner default: the stress baseline must only run
|
|
54
56
|
// stressTest-marked tests regardless of how the runner's default evolves.
|
|
55
57
|
processArgs.push('--mode=stress');
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
Object.defineProperty(exports, "__esModule", {
|
|
4
|
+
value: true
|
|
5
|
+
});
|
|
6
|
+
exports.tsc = tsc;
|
|
7
|
+
var _child_process = require("child_process");
|
|
8
|
+
var _toolchain = require("../utils/toolchain");
|
|
9
|
+
/**
|
|
10
|
+
* `grok tsc ...` runs the workspace TypeScript compiler (the one `@datagrok/build-config` pins) with the
|
|
11
|
+
* given arguments, so package scripts never depend on a locally installed `tsc`. After an emitting run
|
|
12
|
+
* in a library, the assets its sources import are mirrored into dist/.
|
|
13
|
+
*/
|
|
14
|
+
async function tsc(args) {
|
|
15
|
+
const cwd = process.cwd();
|
|
16
|
+
const extra = process.argv.slice(process.argv.indexOf('tsc') + 1);
|
|
17
|
+
const emitting = !extra.includes('--noEmit');
|
|
18
|
+
if (emitting) (0, _toolchain.ensureLibraryExports)(cwd);
|
|
19
|
+
const r = (0, _child_process.spawnSync)(process.execPath, [(0, _toolchain.toolchain)(cwd).tsc, ...extra], {
|
|
20
|
+
stdio: 'inherit',
|
|
21
|
+
cwd
|
|
22
|
+
});
|
|
23
|
+
if (r.status !== 0) return false;
|
|
24
|
+
if (emitting) (0, _toolchain.copyLibraryAssets)(cwd);
|
|
25
|
+
return true;
|
|
26
|
+
}
|
package/bin/grok.js
CHANGED
|
@@ -9,31 +9,45 @@ const argv = require('minimist')(process.argv.slice(2), {
|
|
|
9
9
|
// test.ts / playwright-runner.ts never fired and `--no-retry` was silently ignored
|
|
10
10
|
// (Playwright kept retrying failed specs). Normalize back to the flag the commands read.
|
|
11
11
|
if (argv.retry === false) argv['no-retry'] = true;
|
|
12
|
-
|
|
12
|
+
// The help texts are a large module; load them only when one is printed.
|
|
13
|
+
let _help;
|
|
14
|
+
const help = new Proxy({}, {get: (_, key) => (_help ??= require('./commands/help').help)[key]});
|
|
13
15
|
const runAllCommand = require('./utils/utils').runAll;
|
|
14
16
|
|
|
17
|
+
// Each command module is loaded only when invoked: loading all of them (puppeteer, ts-morph,
|
|
18
|
+
// archiver, inquirer) used to cost ~6 s of start-up on every `grok api` / `grok check`.
|
|
19
|
+
const lazy = (file, name) => (args) => require(`./commands/${file}`)[name](args);
|
|
15
20
|
const commands = {
|
|
16
|
-
add:
|
|
17
|
-
api:
|
|
18
|
-
build:
|
|
19
|
-
check:
|
|
20
|
-
claude:
|
|
21
|
-
config:
|
|
22
|
-
create:
|
|
23
|
-
'docker-gen':
|
|
24
|
-
init:
|
|
25
|
-
link:
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
21
|
+
add: lazy('add', 'add'),
|
|
22
|
+
api: lazy('api', 'api'),
|
|
23
|
+
build: lazy('build', 'build'),
|
|
24
|
+
check: lazy('check', 'check'),
|
|
25
|
+
claude: lazy('claude', 'claude'),
|
|
26
|
+
config: lazy('config', 'config'),
|
|
27
|
+
create: lazy('create', 'create'),
|
|
28
|
+
'docker-gen': lazy('docker-gen', 'dockerGen'),
|
|
29
|
+
init: lazy('init', 'init'),
|
|
30
|
+
link: lazy('link', 'link'),
|
|
31
|
+
login: lazy('login', 'login'),
|
|
32
|
+
publish: lazy('publish', 'publish'),
|
|
33
|
+
report: lazy('report', 'report'),
|
|
34
|
+
run: lazy('run', 'run'),
|
|
35
|
+
test: lazy('test', 'test'),
|
|
36
|
+
tsc: lazy('tsc', 'tsc'),
|
|
37
|
+
testall: lazy('test-all', 'testAll'),
|
|
38
|
+
stresstest: lazy('stress-tests', 'stressTests'),
|
|
39
|
+
migrate: lazy('migrate', 'migrate'),
|
|
40
|
+
server: lazy('server', 'server'),
|
|
41
|
+
s: lazy('server', 'server'),
|
|
42
|
+
setup: lazy('setup', 'setup'),
|
|
35
43
|
};
|
|
36
44
|
|
|
45
|
+
// `--version` is a string option (grok publish --version 1.10), so a bare `grok --version` parses as ''.
|
|
46
|
+
if (argv._.length === 0 && ('version' in argv && argv.version === '' || argv.v === true)) {
|
|
47
|
+
console.log(require('../package.json').version);
|
|
48
|
+
process.exit(0);
|
|
49
|
+
}
|
|
50
|
+
|
|
37
51
|
const onPackageCommandNames = ['api', 'check', 'link', 'publish', 'test'];
|
|
38
52
|
|
|
39
53
|
// A machine-readable run prints its error as JSON on stderr (server.ts) and nothing else:
|
package/bin/utils/color-utils.js
CHANGED
|
@@ -5,7 +5,9 @@ Object.defineProperty(exports, "__esModule", {
|
|
|
5
5
|
});
|
|
6
6
|
exports.isVerbose = exports.info = exports.fail = exports.error = void 0;
|
|
7
7
|
exports.log = log;
|
|
8
|
-
exports.
|
|
8
|
+
exports.setVerbose = void 0;
|
|
9
|
+
exports.step = step;
|
|
10
|
+
exports.warn = exports.success = void 0;
|
|
9
11
|
const error = s => console.log('\x1b[31m%s\x1b[0m', s);
|
|
10
12
|
exports.error = error;
|
|
11
13
|
const info = s => console.log('\x1b[32m%s\x1b[0m', s);
|
|
@@ -42,4 +44,31 @@ function log(s, type = 'plain') {
|
|
|
42
44
|
console.log(s);
|
|
43
45
|
break;
|
|
44
46
|
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* One step of a multi-step command: prints its label, ticks a spinner while [action] runs, and
|
|
51
|
+
* replaces the line with the outcome. A spinner needs a terminal to erase lines, so a CI log
|
|
52
|
+
* (or a redirected stdout) gets one plain line per step instead.
|
|
53
|
+
*/
|
|
54
|
+
async function step(label, action) {
|
|
55
|
+
const tty = process.stdout.isTTY === true;
|
|
56
|
+
const frames = ['-', '\\', '|', '/'];
|
|
57
|
+
let frame = 0;
|
|
58
|
+
const draw = () => process.stdout.write(`\r ${frames[frame++ % frames.length]} ${label} `);
|
|
59
|
+
if (!tty) console.log(` ${label}...`);
|
|
60
|
+
const timer = tty ? setInterval(draw, 120) : null;
|
|
61
|
+
if (tty) draw();
|
|
62
|
+
const finish = (mark, color, text) => {
|
|
63
|
+
if (timer) clearInterval(timer);
|
|
64
|
+
if (tty) process.stdout.write(`\r\x1b[2K \x1b[${color}m${mark}\x1b[0m ${text}\n`);else if (mark !== '+') console.log(` ${mark} ${text}`);
|
|
65
|
+
};
|
|
66
|
+
try {
|
|
67
|
+
const result = await action();
|
|
68
|
+
finish('+', '32', label);
|
|
69
|
+
return result;
|
|
70
|
+
} catch (e) {
|
|
71
|
+
finish('x', '31', label);
|
|
72
|
+
throw e;
|
|
73
|
+
}
|
|
45
74
|
}
|
package/bin/utils/dev-key.js
CHANGED
|
@@ -5,6 +5,9 @@ Object.defineProperty(exports, "__esModule", {
|
|
|
5
5
|
});
|
|
6
6
|
exports.devKeyFetch = devKeyFetch;
|
|
7
7
|
exports.devKeyHeaders = devKeyHeaders;
|
|
8
|
+
exports.keypairToken = keypairToken;
|
|
9
|
+
var keypair = _interopRequireWildcard(require("./keypair"));
|
|
10
|
+
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); }
|
|
8
11
|
const fetch = require('node-fetch');
|
|
9
12
|
|
|
10
13
|
/** `Authorization` header carrying the developer key. */
|
|
@@ -15,13 +18,50 @@ function devKeyHeaders(key, headers = {}) {
|
|
|
15
18
|
};
|
|
16
19
|
}
|
|
17
20
|
|
|
21
|
+
/** One login per API root per process: every publish step reuses the token. */
|
|
22
|
+
const tokenCache = new Map();
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Bearer token obtained by signing a nonce with the server's registered keypair,
|
|
26
|
+
* or `null` when no keypair is configured for [apiRoot] and the caller should
|
|
27
|
+
* fall back to the developer key.
|
|
28
|
+
*/
|
|
29
|
+
async function keypairToken(apiRoot, devKey) {
|
|
30
|
+
const privateKey = keypair.keypairFor(apiRoot, devKey);
|
|
31
|
+
if (!privateKey) return null;
|
|
32
|
+
if (!tokenCache.has(apiRoot)) tokenCache.set(apiRoot, keypair.keyLogin(apiRoot, privateKey));
|
|
33
|
+
try {
|
|
34
|
+
return await tokenCache.get(apiRoot);
|
|
35
|
+
} catch (e) {
|
|
36
|
+
// A server without the keypair endpoints is a reason to use the developer key that is
|
|
37
|
+
// still configured, not to stop: one config usually names stands of both vintages.
|
|
38
|
+
if (e?.name !== 'ServerTooOldError' || !devKey) throw e;
|
|
39
|
+
tokenCache.delete(apiRoot);
|
|
40
|
+
return null;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
18
44
|
/**
|
|
19
|
-
* Calls [url]
|
|
20
|
-
*
|
|
21
|
-
* header
|
|
22
|
-
*
|
|
45
|
+
* Calls [url] as the configured user. With a keypair (`grok login`) that is a
|
|
46
|
+
* session token; otherwise the developer key rides in the `Authorization`
|
|
47
|
+
* header, falling back to [legacyUrl] - which carries the key as a path segment -
|
|
48
|
+
* for servers that predate the header form. Those answer 404 (no such route) or
|
|
49
|
+
* 401 (the route-less path is not on their anonymous allow-list); a server that
|
|
50
|
+
* knows the header form answers neither.
|
|
51
|
+
*
|
|
52
|
+
* [apiRoot] enables the keypair path; without it this stays dev-key only.
|
|
23
53
|
*/
|
|
24
|
-
async function devKeyFetch(url, legacyUrl, key, init = {}) {
|
|
54
|
+
async function devKeyFetch(url, legacyUrl, key, init = {}, apiRoot) {
|
|
55
|
+
if (apiRoot != null) {
|
|
56
|
+
const token = await keypairToken(apiRoot, key);
|
|
57
|
+
if (token) return await fetch(url, {
|
|
58
|
+
...init,
|
|
59
|
+
headers: {
|
|
60
|
+
...init.headers,
|
|
61
|
+
'Authorization': token
|
|
62
|
+
}
|
|
63
|
+
});
|
|
64
|
+
}
|
|
25
65
|
const response = await fetch(url, {
|
|
26
66
|
...init,
|
|
27
67
|
headers: devKeyHeaders(key, init.headers)
|
|
@@ -0,0 +1,292 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
var _interopRequireDefault = require("@babel/runtime/helpers/interopRequireDefault");
|
|
4
|
+
Object.defineProperty(exports, "__esModule", {
|
|
5
|
+
value: true
|
|
6
|
+
});
|
|
7
|
+
exports.ServerTooOldError = exports.MIN_SERVER_VERSION = void 0;
|
|
8
|
+
exports.enrollWithCode = enrollWithCode;
|
|
9
|
+
exports.fingerprint = fingerprint;
|
|
10
|
+
exports.generateKeyPair = generateKeyPair;
|
|
11
|
+
exports.getServerCredentials = getServerCredentials;
|
|
12
|
+
exports.hasKeypair = hasKeypair;
|
|
13
|
+
exports.keyFilePath = keyFilePath;
|
|
14
|
+
exports.keyFromEnv = keyFromEnv;
|
|
15
|
+
exports.keyLogin = keyLogin;
|
|
16
|
+
exports.keypairFor = keypairFor;
|
|
17
|
+
exports.publicPart = publicPart;
|
|
18
|
+
exports.readConfig = readConfig;
|
|
19
|
+
exports.savePrivateKey = savePrivateKey;
|
|
20
|
+
exports.signNonce = signNonce;
|
|
21
|
+
exports.writeConfig = writeConfig;
|
|
22
|
+
var _crypto = _interopRequireDefault(require("crypto"));
|
|
23
|
+
var _fs = _interopRequireDefault(require("fs"));
|
|
24
|
+
var _os = _interopRequireDefault(require("os"));
|
|
25
|
+
var _path = _interopRequireDefault(require("path"));
|
|
26
|
+
var _jsYaml = _interopRequireDefault(require("js-yaml"));
|
|
27
|
+
const grokDir = _path.default.join(_os.default.homedir(), '.grok');
|
|
28
|
+
const confPath = _path.default.join(grokDir, 'config.yaml');
|
|
29
|
+
const keysDir = _path.default.join(grokDir, 'keys');
|
|
30
|
+
|
|
31
|
+
/** Prefix the server wraps a nonce in before checking the signature. */
|
|
32
|
+
const SIGNATURE_PREFIX = 'datagrok-login:';
|
|
33
|
+
/** Generates the keypair `grok login` registers: EC P-256, the JOSE ES256 curve. */
|
|
34
|
+
function generateKeyPair() {
|
|
35
|
+
const {
|
|
36
|
+
publicKey,
|
|
37
|
+
privateKey
|
|
38
|
+
} = _crypto.default.generateKeyPairSync('ec', {
|
|
39
|
+
namedCurve: 'prime256v1'
|
|
40
|
+
});
|
|
41
|
+
return {
|
|
42
|
+
publicKey: publicKey.export({
|
|
43
|
+
format: 'jwk'
|
|
44
|
+
}),
|
|
45
|
+
privateKey: privateKey.export({
|
|
46
|
+
format: 'jwk'
|
|
47
|
+
})
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** The public half of a private JWK — what gets registered on the server. */
|
|
52
|
+
function publicPart(privateKey) {
|
|
53
|
+
const {
|
|
54
|
+
d,
|
|
55
|
+
p,
|
|
56
|
+
q,
|
|
57
|
+
dp,
|
|
58
|
+
dq,
|
|
59
|
+
qi,
|
|
60
|
+
...pub
|
|
61
|
+
} = privateKey;
|
|
62
|
+
return pub;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* RFC 7638 JWK thumbprint, base64url without padding. The server computes the
|
|
67
|
+
* same value from the stored key, so this is what identifies a key at login.
|
|
68
|
+
*/
|
|
69
|
+
function fingerprint(jwk) {
|
|
70
|
+
const members = {
|
|
71
|
+
EC: ['crv', 'kty', 'x', 'y'],
|
|
72
|
+
RSA: ['e', 'kty', 'n']
|
|
73
|
+
};
|
|
74
|
+
const order = members[jwk.kty];
|
|
75
|
+
if (!order) throw new Error(`Unsupported key type "${jwk.kty}" - expected EC or RSA`);
|
|
76
|
+
const canonical = '{' + order.map(m => `${JSON.stringify(m)}:${JSON.stringify(jwk[m])}`).join(',') + '}';
|
|
77
|
+
return _crypto.default.createHash('sha256').update(canonical).digest('base64url');
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Signs the login nonce. [audience] is the API root this client dialed: it is part of what is
|
|
82
|
+
* signed, so a server cannot relay the signature to a second stand where the same key is
|
|
83
|
+
* enrolled. ECDSA signatures go out in the raw r||s form (JOSE's, not DER's), which is what
|
|
84
|
+
* the server's verifier expects.
|
|
85
|
+
*/
|
|
86
|
+
function signNonce(privateKey, audience, nonce) {
|
|
87
|
+
const key = _crypto.default.createPrivateKey({
|
|
88
|
+
key: privateKey,
|
|
89
|
+
format: 'jwk'
|
|
90
|
+
});
|
|
91
|
+
const options = {
|
|
92
|
+
key
|
|
93
|
+
};
|
|
94
|
+
if (privateKey.kty === 'EC') options.dsaEncoding = 'ieee-p1363';
|
|
95
|
+
return _crypto.default.sign('sha256', Buffer.from(`${SIGNATURE_PREFIX}${audience}:${nonce}`), options).toString('base64url');
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Exchanges a keypair for a session token: ask for a nonce, sign it, log in. */
|
|
99
|
+
async function keyLogin(url, privateKey) {
|
|
100
|
+
const fp = fingerprint(publicPart(privateKey));
|
|
101
|
+
const challenge = await postJson(`${url}/users/login/key/challenge`, {
|
|
102
|
+
fingerprint: fp
|
|
103
|
+
});
|
|
104
|
+
const response = await postJson(`${url}/users/login/key`, {
|
|
105
|
+
fingerprint: fp,
|
|
106
|
+
audience: url,
|
|
107
|
+
nonce: challenge.nonce,
|
|
108
|
+
signature: signNonce(privateKey, url, challenge.nonce)
|
|
109
|
+
});
|
|
110
|
+
if (response.isSuccess !== true) throw new Error(response.comment ?? 'Key login failed');
|
|
111
|
+
return response.token;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** Registers [publicKey] with a one-shot enrollment code from the user profile. */
|
|
115
|
+
async function enrollWithCode(url, code, publicKey, name, expires) {
|
|
116
|
+
return await postJson(`${url}/users/keys/enroll`, {
|
|
117
|
+
code,
|
|
118
|
+
name,
|
|
119
|
+
expires,
|
|
120
|
+
publicKey: JSON.stringify(publicKey)
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** The first Datagrok that has the keypair endpoints. */
|
|
125
|
+
const MIN_SERVER_VERSION = exports.MIN_SERVER_VERSION = '1.28';
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Thrown when the server has no keypair endpoints at all. Callers that still hold a developer
|
|
129
|
+
* key catch it and fall back; `grok login` reports it, since there is nothing to fall back to.
|
|
130
|
+
*/
|
|
131
|
+
class ServerTooOldError extends Error {
|
|
132
|
+
constructor(server) {
|
|
133
|
+
super(`${server} does not support keypair authentication — it needs Datagrok ` + `${MIN_SERVER_VERSION} or later. Use a developer key for this server ` + '(grok config add --alias <alias> --server <url> --key <key>), or ask its operator to upgrade.');
|
|
134
|
+
this.server = server;
|
|
135
|
+
this.name = 'ServerTooOldError';
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
exports.ServerTooOldError = ServerTooOldError;
|
|
139
|
+
async function postJson(url, body) {
|
|
140
|
+
const response = await fetch(url, {
|
|
141
|
+
method: 'POST',
|
|
142
|
+
headers: {
|
|
143
|
+
'content-type': 'application/json'
|
|
144
|
+
},
|
|
145
|
+
body: JSON.stringify(body)
|
|
146
|
+
});
|
|
147
|
+
// These routes are anonymous on every server that has them. A server that does not answers
|
|
148
|
+
// 404 (no such route) or 401 (unknown paths are refused before routing) — either way, the
|
|
149
|
+
// keypair endpoints are simply not there.
|
|
150
|
+
if (response.status === 404 || response.status === 401) throw new ServerTooOldError(new URL(url).origin);
|
|
151
|
+
const text = await response.text();
|
|
152
|
+
try {
|
|
153
|
+
return JSON.parse(text);
|
|
154
|
+
} catch {
|
|
155
|
+
throw new Error(`Unexpected response from ${url} (status ${response.status}): ${text.slice(0, 200)}`);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
function keyFilePath(alias) {
|
|
159
|
+
return _path.default.join(keysDir, `${alias}.json`);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** Writes the private key readable only by its owner, the way ssh-keygen does. */
|
|
163
|
+
function savePrivateKey(alias, privateKey) {
|
|
164
|
+
_fs.default.mkdirSync(keysDir, {
|
|
165
|
+
recursive: true,
|
|
166
|
+
mode: 0o700
|
|
167
|
+
});
|
|
168
|
+
const file = keyFilePath(alias);
|
|
169
|
+
_fs.default.writeFileSync(file, JSON.stringify(privateKey, null, 2), {
|
|
170
|
+
mode: 0o600
|
|
171
|
+
});
|
|
172
|
+
// mkdirSync/writeFileSync ignore `mode` when the path already exists.
|
|
173
|
+
try {
|
|
174
|
+
_fs.default.chmodSync(keysDir, 0o700);
|
|
175
|
+
_fs.default.chmodSync(file, 0o600);
|
|
176
|
+
} catch {/* Windows has no POSIX modes; ACLs already keep it in the profile. */}
|
|
177
|
+
return file;
|
|
178
|
+
}
|
|
179
|
+
function readConfig() {
|
|
180
|
+
if (!_fs.default.existsSync(confPath)) return {
|
|
181
|
+
servers: {},
|
|
182
|
+
default: ''
|
|
183
|
+
};
|
|
184
|
+
return _jsYaml.default.load(_fs.default.readFileSync(confPath, {
|
|
185
|
+
encoding: 'utf-8'
|
|
186
|
+
})) ?? {
|
|
187
|
+
servers: {},
|
|
188
|
+
default: ''
|
|
189
|
+
};
|
|
190
|
+
}
|
|
191
|
+
function writeConfig(config) {
|
|
192
|
+
_fs.default.mkdirSync(grokDir, {
|
|
193
|
+
recursive: true
|
|
194
|
+
});
|
|
195
|
+
_fs.default.writeFileSync(confPath, _jsYaml.default.dump(config));
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* The key at [file], or `undefined` when there is none. A config can name a key file the
|
|
200
|
+
* deployment has not filled in yet — CI writes the entry and the secret separately — and that
|
|
201
|
+
* has to mean "fall back to the developer key". A file that exists but cannot be read or parsed
|
|
202
|
+
* is a different thing, and says so.
|
|
203
|
+
*/
|
|
204
|
+
function tryLoadKeyFile(file) {
|
|
205
|
+
const expanded = file.startsWith('~') ? _path.default.join(_os.default.homedir(), file.slice(1)) : file;
|
|
206
|
+
let text;
|
|
207
|
+
try {
|
|
208
|
+
text = _fs.default.readFileSync(expanded, {
|
|
209
|
+
encoding: 'utf-8'
|
|
210
|
+
});
|
|
211
|
+
} catch (error) {
|
|
212
|
+
if (error?.code === 'ENOENT') return undefined;
|
|
213
|
+
throw new Error(`cannot read the private key at ${expanded}: ${error?.message ?? error}`);
|
|
214
|
+
}
|
|
215
|
+
try {
|
|
216
|
+
return parseKey(text);
|
|
217
|
+
} catch (error) {
|
|
218
|
+
throw new Error(`${expanded} is not a private key in JWK form: ${error?.message ?? error}`);
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* The credentials for one server: its config entry, with `GROK_PRIVATE_KEY`
|
|
224
|
+
* taking precedence so a CI job can hold the key in a secret rather than on
|
|
225
|
+
* disk. [hostKey] is an alias, a URL, or empty for the default server.
|
|
226
|
+
*/
|
|
227
|
+
function getServerCredentials(hostKey) {
|
|
228
|
+
const config = readConfig();
|
|
229
|
+
let host = (hostKey === '' || hostKey == null ? config.default : hostKey).trim();
|
|
230
|
+
let entry;
|
|
231
|
+
let alias;
|
|
232
|
+
let url;
|
|
233
|
+
try {
|
|
234
|
+
url = new URL(host).href;
|
|
235
|
+
if (url.endsWith('/')) url = url.slice(0, -1);
|
|
236
|
+
// Several aliases can name the same server. Prefer one that has a keypair: a
|
|
237
|
+
// dev-key-only entry matching first would silently downgrade the login.
|
|
238
|
+
const matches = Object.keys(config.servers ?? {}).filter(name => config.servers[name].url === url);
|
|
239
|
+
alias = matches.find(name => config.servers[name].keyFile || _fs.default.existsSync(keyFilePath(name))) ?? matches[0];
|
|
240
|
+
entry = alias == null ? undefined : config.servers[alias];
|
|
241
|
+
} catch (error) {
|
|
242
|
+
entry = config.servers?.[host];
|
|
243
|
+
if (entry == null) throw new Error(`Unknown server alias. Please add it to ${confPath}`);
|
|
244
|
+
alias = host;
|
|
245
|
+
url = entry.url;
|
|
246
|
+
}
|
|
247
|
+
const cred = {
|
|
248
|
+
url: url,
|
|
249
|
+
alias,
|
|
250
|
+
key: entry?.key,
|
|
251
|
+
login: entry?.login
|
|
252
|
+
};
|
|
253
|
+
if (process.env.GROK_PRIVATE_KEY) {
|
|
254
|
+
cred.privateKey = parseKey(process.env.GROK_PRIVATE_KEY);
|
|
255
|
+
cred.privateKeySource = 'GROK_PRIVATE_KEY';
|
|
256
|
+
} else if (entry?.keyFile && (cred.privateKey = tryLoadKeyFile(entry.keyFile)) != null) cred.privateKeySource = entry.keyFile;else if (alias && (cred.privateKey = tryLoadKeyFile(keyFilePath(alias))) != null) cred.privateKeySource = keyFilePath(alias);
|
|
257
|
+
if (process.env.GROK_LOGIN) cred.login = process.env.GROK_LOGIN;
|
|
258
|
+
return cred;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/** A JWK, raw or base64-encoded — CI secret stores mangle multi-line values. */
|
|
262
|
+
function parseKey(value) {
|
|
263
|
+
const text = value.trim().startsWith('{') ? value : Buffer.from(value.trim(), 'base64').toString('utf-8');
|
|
264
|
+
return JSON.parse(text);
|
|
265
|
+
}
|
|
266
|
+
function hasKeypair(cred) {
|
|
267
|
+
return cred.privateKey != null;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/** A private JWK held in an environment variable, raw or base64-encoded. */
|
|
271
|
+
function keyFromEnv(name) {
|
|
272
|
+
const value = process.env[name];
|
|
273
|
+
return value ? parseKey(value) : undefined;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* The private key to use for [url], or `undefined` to fall back to the developer
|
|
278
|
+
* key. An explicitly supplied [devKey] that differs from the one configured for
|
|
279
|
+
* this server means the caller wants *that* identity - a second CI user, say -
|
|
280
|
+
* so the configured keypair must not silently take over.
|
|
281
|
+
*/
|
|
282
|
+
function keypairFor(url, devKey) {
|
|
283
|
+
let cred;
|
|
284
|
+
try {
|
|
285
|
+
cred = getServerCredentials(url);
|
|
286
|
+
} catch {
|
|
287
|
+
return undefined;
|
|
288
|
+
}
|
|
289
|
+
if (cred.privateKey == null) return undefined;
|
|
290
|
+
if (devKey && devKey !== cred.key) return undefined;
|
|
291
|
+
return cred.privateKey;
|
|
292
|
+
}
|