@walkeros/config 4.7.0-next-1790245733116 → 4.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +23 -1
- package/package.json +1 -1
- package/tsup/__tests__/credentialGuard.test.mjs +136 -0
- package/tsup/__tests__/exportMaps.test.mjs +129 -0
- package/tsup/credentialGuard.mjs +87 -0
- package/tsup/exportMaps.mjs +78 -0
- package/tsup/index.mjs +47 -4
package/CHANGELOG.md
CHANGED
|
@@ -1,6 +1,28 @@
|
|
|
1
1
|
# @walkeros/config
|
|
2
2
|
|
|
3
|
-
## 4.7.0
|
|
3
|
+
## 4.7.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- 64b06de: Packages with several exports, such as the GCP and AWS sources and
|
|
8
|
+
destinations, now publish a settings schema per export. `walkeros validate`
|
|
9
|
+
and MCP `package_get` use the schema of the export a step imports. Pub/Sub,
|
|
10
|
+
BigQuery, SNS and SQS steps pinned to this release or later are checked
|
|
11
|
+
instead of skipped; older pins still report a skip.
|
|
12
|
+
|
|
13
|
+
### Patch Changes
|
|
14
|
+
|
|
15
|
+
- 74821ed: Simulate records every vendor call through one recorder, `observeEnv`
|
|
16
|
+
from `@walkeros/core`, and runs offline: BigQuery, Data Manager and the
|
|
17
|
+
Sheets, GCS and S3 stores reach the network through `env`. Client and `Env`
|
|
18
|
+
types of BigQuery, Firehose, PostHog, SQS and Pub/Sub are structural.
|
|
19
|
+
`@walkeros/cli` exports `resolveExportName` and `selectDevExamples` and drops
|
|
20
|
+
`findExample` and `compareOutput`.
|
|
21
|
+
- 74821ed: Simulate starts only the simulated step, and a simulated transformer
|
|
22
|
+
continues through its `next`. Source simulations list the commands a source
|
|
23
|
+
issues as `elb` calls; `--page-url` sets the web page. Express flows need no
|
|
24
|
+
port. Packages with several exports use per-export examples and mocks, and a
|
|
25
|
+
destination without a mock env is refused instead of calling the vendor.
|
|
4
26
|
|
|
5
27
|
## 4.6.1
|
|
6
28
|
|
package/package.json
CHANGED
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import {
|
|
2
|
+
assertNoPublishedCredentials,
|
|
3
|
+
findPublishedCredential,
|
|
4
|
+
} from '../credentialGuard.mjs';
|
|
5
|
+
|
|
6
|
+
const keyMaterial =
|
|
7
|
+
'MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQC7kJ8sL2mNqRtVxYz'.repeat(
|
|
8
|
+
3,
|
|
9
|
+
);
|
|
10
|
+
const wrapped = keyMaterial.match(/.{1,64}/g).join('\n');
|
|
11
|
+
const realPem = `-----BEGIN PRIVATE KEY-----\n${wrapped}\n-----END PRIVATE KEY-----\n`;
|
|
12
|
+
|
|
13
|
+
describe('assertNoPublishedCredentials', () => {
|
|
14
|
+
it.each([
|
|
15
|
+
['a PEM with real key material', realPem, 'PEM private key'],
|
|
16
|
+
[
|
|
17
|
+
'a PEM with escaped newlines',
|
|
18
|
+
realPem.replace(/\n/g, '\\n'),
|
|
19
|
+
'PEM private key',
|
|
20
|
+
],
|
|
21
|
+
['a live Stripe key', 'sk_live_51Habcdefghijkl', 'live key token sk_live_'],
|
|
22
|
+
['a GitHub token', 'token ghp_abcdefghijklmnop', 'live key token ghp_'],
|
|
23
|
+
['a Slack bot token', 'xoxb-1234-5678-abcd', 'live key token xoxb-'],
|
|
24
|
+
['an AWS access key id', 'AKIAABCDEFGHIJKLMNOP', 'live key token AKIA'],
|
|
25
|
+
])(
|
|
26
|
+
'throws on %s with the path and fix, never the value',
|
|
27
|
+
(_l, value, rule) => {
|
|
28
|
+
const examples = { step: { in: { settings: { credentials: value } } } };
|
|
29
|
+
let message = '';
|
|
30
|
+
try {
|
|
31
|
+
assertNoPublishedCredentials('@walkeros/x', { examples });
|
|
32
|
+
} catch (error) {
|
|
33
|
+
message = error.message;
|
|
34
|
+
}
|
|
35
|
+
expect(message).toBe(
|
|
36
|
+
`@walkeros/x: examples.step.in.settings.credentials looks like a real credential (${rule}). Replace it with a placeholder, e.g. sk_test_..., or a PEM with fewer than 64 characters of key material.`,
|
|
37
|
+
);
|
|
38
|
+
expect(message).not.toContain(value);
|
|
39
|
+
},
|
|
40
|
+
);
|
|
41
|
+
|
|
42
|
+
it.each(['github_pat_', 'ghu_', 'ghs_', 'ghr_'])(
|
|
43
|
+
'throws on a GitHub %s token',
|
|
44
|
+
(prefix) => {
|
|
45
|
+
const value = `${prefix}${'a1B2c3D4e5'.repeat(3)}f6G7h8`;
|
|
46
|
+
expect(() =>
|
|
47
|
+
assertNoPublishedCredentials('@walkeros/x', {
|
|
48
|
+
examples: { step: { in: value } },
|
|
49
|
+
}),
|
|
50
|
+
).toThrow(`(live key token ${prefix})`);
|
|
51
|
+
},
|
|
52
|
+
);
|
|
53
|
+
|
|
54
|
+
it.each([
|
|
55
|
+
[
|
|
56
|
+
'a credential as an object key',
|
|
57
|
+
{ step: { in: { sk_live_51Habcdefghijkl: 'placeholder' } } },
|
|
58
|
+
'examples.step.in.<key>',
|
|
59
|
+
'live key token sk_live_',
|
|
60
|
+
['sk_live_51Habcdefghijkl'],
|
|
61
|
+
],
|
|
62
|
+
[
|
|
63
|
+
'a credential key above a credential value',
|
|
64
|
+
{
|
|
65
|
+
step: {
|
|
66
|
+
in: {
|
|
67
|
+
ghs_abcdefghijklmnopqrstuvwxyz0123456789: {
|
|
68
|
+
token: 'github_pat_abcdefghijklmnopqrstuvwxyz0123456789',
|
|
69
|
+
},
|
|
70
|
+
},
|
|
71
|
+
},
|
|
72
|
+
},
|
|
73
|
+
'examples.step.in.<key>',
|
|
74
|
+
'live key token ghs_',
|
|
75
|
+
[
|
|
76
|
+
'ghs_abcdefghijklmnopqrstuvwxyz0123456789',
|
|
77
|
+
'github_pat_abcdefghijklmnopqrstuvwxyz0123456789',
|
|
78
|
+
],
|
|
79
|
+
],
|
|
80
|
+
])(
|
|
81
|
+
'throws on %s without the key or value text',
|
|
82
|
+
(_l, examples, path, rule, secrets) => {
|
|
83
|
+
let message = '';
|
|
84
|
+
try {
|
|
85
|
+
assertNoPublishedCredentials('@walkeros/x', { examples });
|
|
86
|
+
} catch (error) {
|
|
87
|
+
message = error.message;
|
|
88
|
+
}
|
|
89
|
+
expect(message).toBe(
|
|
90
|
+
`@walkeros/x: ${path} looks like a real credential (${rule}). Replace it with a placeholder, e.g. sk_test_..., or a PEM with fewer than 64 characters of key material.`,
|
|
91
|
+
);
|
|
92
|
+
for (const secret of secrets) expect(message).not.toContain(secret);
|
|
93
|
+
},
|
|
94
|
+
);
|
|
95
|
+
|
|
96
|
+
it('passes a plain header key with a placeholder value', () => {
|
|
97
|
+
expect(() =>
|
|
98
|
+
assertNoPublishedCredentials('@walkeros/x', {
|
|
99
|
+
examples: {
|
|
100
|
+
step: { in: { headers: { Authorization: 'Bearer ***' } } },
|
|
101
|
+
},
|
|
102
|
+
}),
|
|
103
|
+
).not.toThrow();
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it('names the section and array index of the hit', () => {
|
|
107
|
+
expect(
|
|
108
|
+
findPublishedCredential({
|
|
109
|
+
examples: {},
|
|
110
|
+
hints: { auth: { examples: ['ok', 'sk_live_abc'] } },
|
|
111
|
+
}),
|
|
112
|
+
).toEqual({
|
|
113
|
+
path: 'hints.auth.examples.1',
|
|
114
|
+
rule: 'live key token sk_live_',
|
|
115
|
+
});
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it.each([
|
|
119
|
+
['a short PEM placeholder', '-----BEGIN PRIVATE KEY-----\ntest'],
|
|
120
|
+
[
|
|
121
|
+
'an escaped short PEM placeholder',
|
|
122
|
+
'-----BEGIN PRIVATE KEY-----\\ntest\\n-----END PRIVATE KEY-----\\n',
|
|
123
|
+
],
|
|
124
|
+
['a test Stripe key', 'sk_test_x'],
|
|
125
|
+
['the AWS documentation key id', 'AKIDEXAMPLE'],
|
|
126
|
+
['a prefix inside a word', 'mask_live_ and task_ghp_x'],
|
|
127
|
+
])('passes %s', (_l, value) => {
|
|
128
|
+
expect(() =>
|
|
129
|
+
assertNoPublishedCredentials('@walkeros/x', {
|
|
130
|
+
examples: { step: { in: value } },
|
|
131
|
+
exportExamples: { other: { step: { out: [value] } } },
|
|
132
|
+
hints: undefined,
|
|
133
|
+
}),
|
|
134
|
+
).not.toThrow();
|
|
135
|
+
});
|
|
136
|
+
});
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import {
|
|
2
|
+
assertExportMaps,
|
|
3
|
+
assertStepSettings,
|
|
4
|
+
publishedExports,
|
|
5
|
+
} from '../exportMaps.mjs';
|
|
6
|
+
|
|
7
|
+
const two = { destinationA: 'A', destinationB: 'B' };
|
|
8
|
+
const examples = { destinationA: {}, destinationB: {} };
|
|
9
|
+
const schemas = {
|
|
10
|
+
destinationA: { settings: {} },
|
|
11
|
+
destinationB: { settings: {}, setup: {} },
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
describe('assertExportMaps', () => {
|
|
15
|
+
it.each([
|
|
16
|
+
['no declared exports and no maps', undefined, {}],
|
|
17
|
+
['a single export and no maps', { destinationA: 'A' }, {}],
|
|
18
|
+
[
|
|
19
|
+
'two exports with matching maps',
|
|
20
|
+
two,
|
|
21
|
+
{ exportExamples: examples, exportSchemas: schemas },
|
|
22
|
+
],
|
|
23
|
+
])('passes for %s', (_label, declared, maps) => {
|
|
24
|
+
expect(() => assertExportMaps('@walkeros/x', declared, maps)).not.toThrow();
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it.each([
|
|
28
|
+
[
|
|
29
|
+
'exportSchemas missing',
|
|
30
|
+
two,
|
|
31
|
+
{ exportExamples: examples },
|
|
32
|
+
'@walkeros/x: exportSchemas is missing',
|
|
33
|
+
],
|
|
34
|
+
[
|
|
35
|
+
'exportExamples missing',
|
|
36
|
+
two,
|
|
37
|
+
{ exportSchemas: schemas },
|
|
38
|
+
'@walkeros/x: exportExamples is missing',
|
|
39
|
+
],
|
|
40
|
+
[
|
|
41
|
+
'an export missing from exportSchemas',
|
|
42
|
+
two,
|
|
43
|
+
{
|
|
44
|
+
exportExamples: examples,
|
|
45
|
+
exportSchemas: { destinationA: { settings: {} } },
|
|
46
|
+
},
|
|
47
|
+
'@walkeros/x: exportSchemas is missing export "destinationB"',
|
|
48
|
+
],
|
|
49
|
+
[
|
|
50
|
+
'an undeclared export in exportSchemas',
|
|
51
|
+
two,
|
|
52
|
+
{
|
|
53
|
+
exportExamples: examples,
|
|
54
|
+
exportSchemas: { ...schemas, destinationC: { settings: {} } },
|
|
55
|
+
},
|
|
56
|
+
'@walkeros/x: exportSchemas has undeclared export "destinationC"',
|
|
57
|
+
],
|
|
58
|
+
[
|
|
59
|
+
'an exportSchemas entry without settings',
|
|
60
|
+
two,
|
|
61
|
+
{
|
|
62
|
+
exportExamples: examples,
|
|
63
|
+
exportSchemas: { ...schemas, destinationB: { setup: {} } },
|
|
64
|
+
},
|
|
65
|
+
'@walkeros/x: exportSchemas.destinationB has no settings schema',
|
|
66
|
+
],
|
|
67
|
+
[
|
|
68
|
+
'an export missing from exportExamples',
|
|
69
|
+
two,
|
|
70
|
+
{ exportExamples: { destinationA: {} }, exportSchemas: schemas },
|
|
71
|
+
'@walkeros/x: exportExamples is missing export "destinationB"',
|
|
72
|
+
],
|
|
73
|
+
[
|
|
74
|
+
'maps on a single-export package',
|
|
75
|
+
{ destinationA: 'A' },
|
|
76
|
+
{ exportSchemas: { destinationA: { settings: {} } } },
|
|
77
|
+
'@walkeros/x: exportSchemas is set but the package declares fewer than two exports',
|
|
78
|
+
],
|
|
79
|
+
])('throws for %s', (_label, declared, maps, message) => {
|
|
80
|
+
expect(() => assertExportMaps('@walkeros/x', declared, maps)).toThrow(
|
|
81
|
+
new Error(message),
|
|
82
|
+
);
|
|
83
|
+
});
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
describe('assertStepSettings', () => {
|
|
87
|
+
it.each(['source', 'transformer', 'destination', 'store'])(
|
|
88
|
+
'throws for a %s package without schemas.settings',
|
|
89
|
+
(type) => {
|
|
90
|
+
expect(() => assertStepSettings('@walkeros/x', type, {})).toThrow(
|
|
91
|
+
new Error('@walkeros/x: schemas.settings is missing'),
|
|
92
|
+
);
|
|
93
|
+
},
|
|
94
|
+
);
|
|
95
|
+
|
|
96
|
+
it.each([
|
|
97
|
+
['a destination with settings', 'destination', { settings: {} }],
|
|
98
|
+
['a package without a type', undefined, {}],
|
|
99
|
+
['a non-step package', 'core', {}],
|
|
100
|
+
])('passes for %s', (_label, type, published) => {
|
|
101
|
+
expect(() =>
|
|
102
|
+
assertStepSettings('@walkeros/x', type, published),
|
|
103
|
+
).not.toThrow();
|
|
104
|
+
});
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
describe('publishedExports', () => {
|
|
108
|
+
it.each([
|
|
109
|
+
[
|
|
110
|
+
'a multi-export package keeps its declared exports',
|
|
111
|
+
{ type: 'destination', exports: two },
|
|
112
|
+
two,
|
|
113
|
+
],
|
|
114
|
+
[
|
|
115
|
+
'a single-export package keeps its one declared export',
|
|
116
|
+
{ type: 'destination', exports: { destinationA: 'A' } },
|
|
117
|
+
{ destinationA: 'A' },
|
|
118
|
+
],
|
|
119
|
+
[
|
|
120
|
+
'a step package without declared exports publishes an empty list',
|
|
121
|
+
{ type: 'source' },
|
|
122
|
+
{},
|
|
123
|
+
],
|
|
124
|
+
['a non-step package publishes no list', { type: 'core' }, undefined],
|
|
125
|
+
['a package without a type publishes no list', {}, undefined],
|
|
126
|
+
])('%s', (_label, walkerOS, expected) => {
|
|
127
|
+
expect(publishedExports(walkerOS)).toEqual(expected);
|
|
128
|
+
});
|
|
129
|
+
});
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
// Guard for published step metadata: dist/walkerOS.json ships to npm and the
|
|
2
|
+
// hub, so a real credential pasted into an example or a hint must fail the
|
|
3
|
+
// build. Placeholders pass: a PEM with under 64 characters of key material,
|
|
4
|
+
// `sk_test_...`, `AKIDEXAMPLE`.
|
|
5
|
+
|
|
6
|
+
// Base64 characters of key material a PEM body needs to count as real.
|
|
7
|
+
const MIN_PEM_KEY_CHARS = 64;
|
|
8
|
+
|
|
9
|
+
// The marker, then the body: base64 characters, whitespace or an escaped
|
|
10
|
+
// `\n`. The alternatives start on different characters, so the scan is linear.
|
|
11
|
+
const RE_PEM_BODY =
|
|
12
|
+
/-----BEGIN [A-Z ]*PRIVATE KEY-----((?:[A-Za-z0-9+/=]|\s|\\n)*)/g;
|
|
13
|
+
|
|
14
|
+
const RE_LIVE_TOKEN =
|
|
15
|
+
/(?:^|[^A-Za-z0-9_])(sk_live_|github_pat_|ghp_|gho_|ghu_|ghs_|ghr_|xoxb-|xoxp-|AKIA[A-Z0-9]{16})/;
|
|
16
|
+
|
|
17
|
+
// Path segment reported for an object key that breaks a rule: the key text is
|
|
18
|
+
// the credential, so it never appears in the message.
|
|
19
|
+
const KEY_PLACEHOLDER = '<key>';
|
|
20
|
+
|
|
21
|
+
function countKeyChars(body) {
|
|
22
|
+
return body.replace(/\\n|\s/g, '').length;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** The rule a string breaks, or undefined when it looks like a placeholder. */
|
|
26
|
+
function credentialRule(value) {
|
|
27
|
+
RE_PEM_BODY.lastIndex = 0;
|
|
28
|
+
let match;
|
|
29
|
+
while ((match = RE_PEM_BODY.exec(value)) !== null) {
|
|
30
|
+
if (countKeyChars(match[1]) >= MIN_PEM_KEY_CHARS) return 'PEM private key';
|
|
31
|
+
}
|
|
32
|
+
const token = RE_LIVE_TOKEN.exec(value);
|
|
33
|
+
if (token) {
|
|
34
|
+
const prefix = token[1].startsWith('AKIA') ? 'AKIA' : token[1];
|
|
35
|
+
return `live key token ${prefix}`;
|
|
36
|
+
}
|
|
37
|
+
return undefined;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function findInValue(value, path) {
|
|
41
|
+
if (typeof value === 'string') {
|
|
42
|
+
const rule = credentialRule(value);
|
|
43
|
+
return rule ? { path, rule } : undefined;
|
|
44
|
+
}
|
|
45
|
+
if (Array.isArray(value)) {
|
|
46
|
+
for (let i = 0; i < value.length; i++) {
|
|
47
|
+
const hit = findInValue(value[i], `${path}.${i}`);
|
|
48
|
+
if (hit) return hit;
|
|
49
|
+
}
|
|
50
|
+
return undefined;
|
|
51
|
+
}
|
|
52
|
+
if (value && typeof value === 'object') {
|
|
53
|
+
for (const [key, child] of Object.entries(value)) {
|
|
54
|
+
// A key is published text too: check it before descending.
|
|
55
|
+
const keyRule = credentialRule(key);
|
|
56
|
+
if (keyRule) return { path: `${path}.${KEY_PLACEHOLDER}`, rule: keyRule };
|
|
57
|
+
const hit = findInValue(child, `${path}.${key}`);
|
|
58
|
+
if (hit) return hit;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
return undefined;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* The first serialized string in `sections` (e.g. `{ examples, hints }`) that
|
|
66
|
+
* looks like a real credential, as `{ path, rule }`; undefined when none does.
|
|
67
|
+
* The value itself is never returned.
|
|
68
|
+
*/
|
|
69
|
+
function findPublishedCredential(sections) {
|
|
70
|
+
for (const [name, value] of Object.entries(sections)) {
|
|
71
|
+
if (value === undefined) continue;
|
|
72
|
+
const hit = findInValue(value, name);
|
|
73
|
+
if (hit) return hit;
|
|
74
|
+
}
|
|
75
|
+
return undefined;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Throw when a published section carries a real-looking credential. */
|
|
79
|
+
function assertNoPublishedCredentials(packageName, sections) {
|
|
80
|
+
const hit = findPublishedCredential(sections);
|
|
81
|
+
if (!hit) return;
|
|
82
|
+
throw new Error(
|
|
83
|
+
`${packageName}: ${hit.path} looks like a real credential (${hit.rule}). Replace it with a placeholder, e.g. sk_test_..., or a PEM with fewer than 64 characters of key material.`,
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export { findPublishedCredential, assertNoPublishedCredentials };
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
// Guards for published step metadata in dist/walkerOS.json. A package that
|
|
2
|
+
// declares two or more exports in package.json `walkerOS.exports` ships
|
|
3
|
+
// `exportExamples` and `exportSchemas` keyed by exactly those export names,
|
|
4
|
+
// the default export included, so a reader never falls back to another
|
|
5
|
+
// export's examples or settings schema.
|
|
6
|
+
|
|
7
|
+
const STEP_TYPES = ['source', 'transformer', 'destination', 'store'];
|
|
8
|
+
|
|
9
|
+
function isRecord(value) {
|
|
10
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function assertKeys(packageName, mapName, map, declared) {
|
|
14
|
+
if (!isRecord(map)) throw new Error(`${packageName}: ${mapName} is missing`);
|
|
15
|
+
for (const name of declared) {
|
|
16
|
+
if (!(name in map))
|
|
17
|
+
throw new Error(`${packageName}: ${mapName} is missing export "${name}"`);
|
|
18
|
+
}
|
|
19
|
+
for (const name of Object.keys(map)) {
|
|
20
|
+
if (!declared.includes(name))
|
|
21
|
+
throw new Error(
|
|
22
|
+
`${packageName}: ${mapName} has undeclared export "${name}"`,
|
|
23
|
+
);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Throw when the per-export maps do not match the declared exports: with two
|
|
29
|
+
* or more exports both maps list every export and each `exportSchemas` entry
|
|
30
|
+
* carries a `settings` schema; with fewer, neither map is present.
|
|
31
|
+
*/
|
|
32
|
+
function assertExportMaps(packageName, declaredExports, maps) {
|
|
33
|
+
const declared = isRecord(declaredExports)
|
|
34
|
+
? Object.keys(declaredExports)
|
|
35
|
+
: [];
|
|
36
|
+
const { exportExamples, exportSchemas } = maps;
|
|
37
|
+
|
|
38
|
+
if (declared.length < 2) {
|
|
39
|
+
for (const [mapName, map] of [
|
|
40
|
+
['exportExamples', exportExamples],
|
|
41
|
+
['exportSchemas', exportSchemas],
|
|
42
|
+
]) {
|
|
43
|
+
if (map !== undefined)
|
|
44
|
+
throw new Error(
|
|
45
|
+
`${packageName}: ${mapName} is set but the package declares fewer than two exports`,
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
assertKeys(packageName, 'exportExamples', exportExamples, declared);
|
|
52
|
+
assertKeys(packageName, 'exportSchemas', exportSchemas, declared);
|
|
53
|
+
for (const [name, entry] of Object.entries(exportSchemas)) {
|
|
54
|
+
if (!isRecord(entry) || !isRecord(entry.settings))
|
|
55
|
+
throw new Error(
|
|
56
|
+
`${packageName}: exportSchemas.${name} has no settings schema`,
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Throw when a step package (source, transformer, destination, store) publishes no settings schema. */
|
|
62
|
+
function assertStepSettings(packageName, type, schemas) {
|
|
63
|
+
if (!STEP_TYPES.includes(type)) return;
|
|
64
|
+
if (!isRecord(schemas) || !isRecord(schemas.settings))
|
|
65
|
+
throw new Error(`${packageName}: schemas.settings is missing`);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* The `$meta.exports` a package publishes: its declared `walkerOS.exports`,
|
|
70
|
+
* else an empty list for a step package, so a reader can tell a package
|
|
71
|
+
* without named exports from an older package that published no list.
|
|
72
|
+
*/
|
|
73
|
+
function publishedExports(walkerOS) {
|
|
74
|
+
if (isRecord(walkerOS.exports)) return walkerOS.exports;
|
|
75
|
+
return STEP_TYPES.includes(walkerOS.type) ? {} : undefined;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export { assertExportMaps, assertStepSettings, publishedExports };
|
package/tsup/index.mjs
CHANGED
|
@@ -2,6 +2,12 @@ import { defineConfig } from 'tsup';
|
|
|
2
2
|
import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'fs';
|
|
3
3
|
import { resolve, join } from 'path';
|
|
4
4
|
import { pathToFileURL } from 'url';
|
|
5
|
+
import { assertNoPublishedCredentials } from './credentialGuard.mjs';
|
|
6
|
+
import {
|
|
7
|
+
assertExportMaps,
|
|
8
|
+
assertStepSettings,
|
|
9
|
+
publishedExports,
|
|
10
|
+
} from './exportMaps.mjs';
|
|
5
11
|
|
|
6
12
|
const baseConfig = {
|
|
7
13
|
entry: ['src/index.ts'],
|
|
@@ -16,7 +22,10 @@ const getVersion = () => {
|
|
|
16
22
|
const pkg = JSON.parse(readFileSync(packagePath, 'utf8'));
|
|
17
23
|
return pkg.version || '0.0.0';
|
|
18
24
|
} catch (error) {
|
|
19
|
-
console.warn(
|
|
25
|
+
console.warn(
|
|
26
|
+
'Could not read package.json for version injection:',
|
|
27
|
+
error.message,
|
|
28
|
+
);
|
|
20
29
|
return '0.0.0';
|
|
21
30
|
}
|
|
22
31
|
};
|
|
@@ -140,7 +149,9 @@ const buildDev = (customConfig = {}) => {
|
|
|
140
149
|
// Check the built module exists
|
|
141
150
|
const devMjsPath = resolve(cwd, 'dist/dev.mjs');
|
|
142
151
|
if (!existsSync(devMjsPath)) {
|
|
143
|
-
console.warn(
|
|
152
|
+
console.warn(
|
|
153
|
+
'[buildDev] dist/dev.mjs not found, skipping walkerOS.json generation',
|
|
154
|
+
);
|
|
144
155
|
return;
|
|
145
156
|
}
|
|
146
157
|
|
|
@@ -173,7 +184,8 @@ const buildDev = (customConfig = {}) => {
|
|
|
173
184
|
: [walkerOS.platform];
|
|
174
185
|
}
|
|
175
186
|
if (walkerOS.renderer) meta.renderer = walkerOS.renderer;
|
|
176
|
-
|
|
187
|
+
const exports = publishedExports(walkerOS);
|
|
188
|
+
if (exports) meta.exports = exports;
|
|
177
189
|
|
|
178
190
|
// Docs URL (manual, from walkerOS.docs in package.json)
|
|
179
191
|
if (walkerOS.docs) meta.docs = walkerOS.docs;
|
|
@@ -181,7 +193,9 @@ const buildDev = (customConfig = {}) => {
|
|
|
181
193
|
// Source URL (auto-derived from repository.directory)
|
|
182
194
|
const repo = pkg.repository;
|
|
183
195
|
if (repo?.directory) {
|
|
184
|
-
const repoUrl = (repo.url || '')
|
|
196
|
+
const repoUrl = (repo.url || '')
|
|
197
|
+
.replace(/^git\+/, '')
|
|
198
|
+
.replace(/\.git$/, '');
|
|
185
199
|
if (repoUrl) meta.source = `${repoUrl}/tree/main/${repo.directory}/src`;
|
|
186
200
|
}
|
|
187
201
|
|
|
@@ -191,10 +205,39 @@ const buildDev = (customConfig = {}) => {
|
|
|
191
205
|
: undefined;
|
|
192
206
|
|
|
193
207
|
const output = { $meta: meta, schemas, examples };
|
|
208
|
+
|
|
209
|
+
// Multi-export packages ship examples per export, keyed by export name
|
|
210
|
+
if (devModule.exportExamples) {
|
|
211
|
+
const exportExamples = toSerializable(devModule.exportExamples);
|
|
212
|
+
if (exportExamples && Object.keys(exportExamples).length > 0)
|
|
213
|
+
output.exportExamples = exportExamples;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// Multi-export packages ship schemas per export, same shape as schemas
|
|
217
|
+
if (devModule.exportSchemas) {
|
|
218
|
+
const exportSchemas = toSerializable(devModule.exportSchemas);
|
|
219
|
+
if (exportSchemas && Object.keys(exportSchemas).length > 0)
|
|
220
|
+
output.exportSchemas = exportSchemas;
|
|
221
|
+
}
|
|
222
|
+
|
|
194
223
|
if (hints && Object.keys(hints).length > 0) {
|
|
195
224
|
output.hints = hints;
|
|
196
225
|
}
|
|
197
226
|
|
|
227
|
+
// Published metadata must never carry a real credential: fail the build
|
|
228
|
+
assertNoPublishedCredentials(pkg.name, {
|
|
229
|
+
examples: output.examples,
|
|
230
|
+
exportExamples: output.exportExamples,
|
|
231
|
+
hints: output.hints,
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
// Per-export maps match walkerOS.exports; step packages ship settings
|
|
235
|
+
assertExportMaps(pkg.name, walkerOS.exports, {
|
|
236
|
+
exportExamples: output.exportExamples,
|
|
237
|
+
exportSchemas: output.exportSchemas,
|
|
238
|
+
});
|
|
239
|
+
assertStepSettings(pkg.name, walkerOS.type, schemas);
|
|
240
|
+
|
|
198
241
|
// Validate
|
|
199
242
|
if (Object.keys(schemas).length === 0) {
|
|
200
243
|
console.warn('[buildDev] Warning: schemas is empty');
|