rubrkit 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +126 -0
- package/bin/rubrkit.js +16 -0
- package/package.json +28 -0
- package/src/adapters.js +118 -0
- package/src/api.js +101 -0
- package/src/args.js +175 -0
- package/src/cli.js +93 -0
- package/src/config.js +169 -0
- package/src/errors.js +55 -0
- package/src/formats.js +222 -0
- package/src/index.d.ts +76 -0
- package/src/localChecks.js +680 -0
- package/src/manifest.js +118 -0
- package/src/pathSafety.js +62 -0
- package/src/prompts.js +149 -0
- package/src/pull.js +676 -0
- package/src/sdk.js +443 -0
- package/src/testingCli.js +431 -0
package/src/manifest.js
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import crypto from 'node:crypto';
|
|
2
|
+
import fs from 'node:fs/promises';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
|
|
5
|
+
export const MANIFEST_RELATIVE_PATH = '.rubrkit/manifest.json';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* @param {string} content
|
|
9
|
+
*/
|
|
10
|
+
export function hashContent(content) {
|
|
11
|
+
return `sha256:${crypto.createHash('sha256').update(content).digest('hex')}`;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* @param {string} root
|
|
16
|
+
* @param {Pick<typeof fs, 'readFile'>} [fsImpl]
|
|
17
|
+
*/
|
|
18
|
+
export async function loadManifest(root, fsImpl = fs) {
|
|
19
|
+
const manifestPath = path.join(root, MANIFEST_RELATIVE_PATH);
|
|
20
|
+
|
|
21
|
+
try {
|
|
22
|
+
const parsed = JSON.parse(await fsImpl.readFile(manifestPath, 'utf8'));
|
|
23
|
+
return normalizeManifest(parsed);
|
|
24
|
+
} catch (error) {
|
|
25
|
+
if (error && typeof error === 'object' && 'code' in error && error.code === 'ENOENT') {
|
|
26
|
+
return createEmptyManifest();
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
throw error;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* @param {string} root
|
|
35
|
+
* @param {ReturnType<typeof createEmptyManifest>} manifest
|
|
36
|
+
* @param {Pick<typeof fs, 'mkdir' | 'writeFile'>} [fsImpl]
|
|
37
|
+
*/
|
|
38
|
+
export async function writeManifest(root, manifest, fsImpl = fs) {
|
|
39
|
+
const manifestPath = path.join(root, MANIFEST_RELATIVE_PATH);
|
|
40
|
+
const nextManifest = {
|
|
41
|
+
...manifest,
|
|
42
|
+
updatedAt: new Date().toISOString(),
|
|
43
|
+
entries: [...manifest.entries].sort((left, right) => left.destinationPath.localeCompare(right.destinationPath)),
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
await fsImpl.mkdir(path.dirname(manifestPath), { recursive: true });
|
|
47
|
+
await fsImpl.writeFile(manifestPath, `${JSON.stringify(nextManifest, null, 2)}\n`, 'utf8');
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function createEmptyManifest() {
|
|
51
|
+
return {
|
|
52
|
+
schemaVersion: 1,
|
|
53
|
+
generatedBy: 'rubrkit-cli',
|
|
54
|
+
updatedAt: null,
|
|
55
|
+
entries: [],
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* @param {unknown} value
|
|
61
|
+
*/
|
|
62
|
+
function normalizeManifest(value) {
|
|
63
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
|
64
|
+
return createEmptyManifest();
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const record = /** @type {Record<string, unknown>} */ (value);
|
|
68
|
+
const entries = Array.isArray(record.entries)
|
|
69
|
+
? record.entries.filter((entry) => entry && typeof entry === 'object').map((entry) => ({ ...entry }))
|
|
70
|
+
: [];
|
|
71
|
+
|
|
72
|
+
return {
|
|
73
|
+
schemaVersion: 1,
|
|
74
|
+
generatedBy: 'rubrkit-cli',
|
|
75
|
+
updatedAt: typeof record.updatedAt === 'string' ? record.updatedAt : null,
|
|
76
|
+
entries,
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* @param {ReturnType<typeof createEmptyManifest>} manifest
|
|
82
|
+
* @param {{ artifactBundleId: string, artifactId: string, destinationPath: string }} lookup
|
|
83
|
+
*/
|
|
84
|
+
export function findManifestEntry(manifest, lookup) {
|
|
85
|
+
return manifest.entries.find(
|
|
86
|
+
(entry) =>
|
|
87
|
+
entry.artifactBundleId === lookup.artifactBundleId &&
|
|
88
|
+
entry.artifactId === lookup.artifactId &&
|
|
89
|
+
entry.destinationPath === lookup.destinationPath,
|
|
90
|
+
);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* @param {ReturnType<typeof createEmptyManifest>} manifest
|
|
95
|
+
* @param {Record<string, unknown>} nextEntry
|
|
96
|
+
*/
|
|
97
|
+
export function upsertManifestEntry(manifest, nextEntry) {
|
|
98
|
+
const index = manifest.entries.findIndex(
|
|
99
|
+
(entry) =>
|
|
100
|
+
entry.artifactBundleId === nextEntry.artifactBundleId &&
|
|
101
|
+
entry.artifactId === nextEntry.artifactId &&
|
|
102
|
+
entry.destinationPath === nextEntry.destinationPath,
|
|
103
|
+
);
|
|
104
|
+
|
|
105
|
+
if (index === -1) {
|
|
106
|
+
manifest.entries.push(nextEntry);
|
|
107
|
+
} else {
|
|
108
|
+
manifest.entries[index] = nextEntry;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* @param {ReturnType<typeof createEmptyManifest>} manifest
|
|
114
|
+
* @param {Set<string>} destinationPaths
|
|
115
|
+
*/
|
|
116
|
+
export function removeManifestEntriesByDestination(manifest, destinationPaths) {
|
|
117
|
+
manifest.entries = manifest.entries.filter((entry) => !destinationPaths.has(String(entry.destinationPath)));
|
|
118
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
|
|
3
|
+
import { RubrkitCliError } from './errors.js';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* @param {string} input
|
|
7
|
+
*/
|
|
8
|
+
export function normalizeArtifactPath(input) {
|
|
9
|
+
const value = String(input ?? '').replace(/\\/g, '/').trim();
|
|
10
|
+
|
|
11
|
+
if (!value || value.startsWith('/') || /^[a-zA-Z]:/.test(value)) {
|
|
12
|
+
throw new RubrkitCliError(`Unsafe artifact path "${input}".`, { code: 'unsafe_artifact_path' });
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const parts = value.split('/').filter((part) => part && part !== '.');
|
|
16
|
+
|
|
17
|
+
if (parts.length === 0 || parts.some((part) => part === '..')) {
|
|
18
|
+
throw new RubrkitCliError(`Unsafe artifact path "${input}".`, { code: 'unsafe_artifact_path' });
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
return parts.join('/');
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* @param {string} root
|
|
26
|
+
* @param {string} relativePath
|
|
27
|
+
*/
|
|
28
|
+
export function resolveInsideRoot(root, relativePath) {
|
|
29
|
+
const normalizedRoot = path.resolve(root);
|
|
30
|
+
const normalizedRelativePath = normalizeArtifactPath(relativePath);
|
|
31
|
+
const target = path.resolve(normalizedRoot, ...normalizedRelativePath.split('/'));
|
|
32
|
+
const relative = path.relative(normalizedRoot, target);
|
|
33
|
+
|
|
34
|
+
if (relative.startsWith('..') || path.isAbsolute(relative)) {
|
|
35
|
+
throw new RubrkitCliError(`Refusing to write outside the destination root: ${relativePath}`, {
|
|
36
|
+
code: 'path_outside_destination',
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
return target;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* @param {string} root
|
|
45
|
+
* @param {string} target
|
|
46
|
+
*/
|
|
47
|
+
export function relativeToRoot(root, target) {
|
|
48
|
+
return path.relative(path.resolve(root), path.resolve(target)).replace(/\\/g, '/');
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* @param {string | null | undefined} value
|
|
53
|
+
*/
|
|
54
|
+
export function slugifyPathSegment(value) {
|
|
55
|
+
const slug = String(value ?? 'artifact-bundle')
|
|
56
|
+
.trim()
|
|
57
|
+
.toLowerCase()
|
|
58
|
+
.replace(/[^a-z0-9._-]+/g, '-')
|
|
59
|
+
.replace(/^-+|-+$/g, '');
|
|
60
|
+
|
|
61
|
+
return slug || 'artifact-bundle';
|
|
62
|
+
}
|
package/src/prompts.js
ADDED
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import readline from 'node:readline/promises';
|
|
2
|
+
|
|
3
|
+
import { RubrkitCliError } from './errors.js';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* @param {{
|
|
7
|
+
* bundles: Array<Record<string, any>>,
|
|
8
|
+
* loadFiles(bundle: Record<string, any>): Promise<Array<Record<string, any>>>,
|
|
9
|
+
* stdin: NodeJS.ReadableStream,
|
|
10
|
+
* stdout: NodeJS.WritableStream,
|
|
11
|
+
* }} params
|
|
12
|
+
*/
|
|
13
|
+
export async function promptForPullSelections({ bundles, loadFiles, stdin, stdout }) {
|
|
14
|
+
if (bundles.length === 0) {
|
|
15
|
+
throw new RubrkitCliError('No active artifact bundles are available to pull.', { code: 'no_artifact_bundles' });
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const inputMeta = /** @type {{ isTTY?: boolean }} */ (stdin);
|
|
19
|
+
const scriptedAnswers = inputMeta.isTTY ? null : await readScriptedAnswers(stdin);
|
|
20
|
+
const rl = scriptedAnswers ? null : readline.createInterface({ input: stdin, output: stdout });
|
|
21
|
+
|
|
22
|
+
try {
|
|
23
|
+
stdout.write('Artifact bundles:\n');
|
|
24
|
+
bundles.forEach((bundle, index) => {
|
|
25
|
+
stdout.write(` ${index + 1}. ${bundle.name ?? bundle.id} (${bundle.id})\n`);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
const bundleAnswer = await ask({ rl, scriptedAnswers, stdout }, 'Choose bundles by number, comma list, or "all": ');
|
|
29
|
+
const bundleIndexes = parseSelection(bundleAnswer, bundles.length);
|
|
30
|
+
const selectedBundles = bundleIndexes.map((index) => bundles[index]);
|
|
31
|
+
const selections = [];
|
|
32
|
+
|
|
33
|
+
for (const bundle of selectedBundles) {
|
|
34
|
+
const files = await loadFiles(bundle);
|
|
35
|
+
|
|
36
|
+
if (files.length === 0) {
|
|
37
|
+
continue;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
stdout.write(`\nFiles in ${bundle.name ?? bundle.id}:\n`);
|
|
41
|
+
files.forEach((file, index) => {
|
|
42
|
+
const primary = file.isPrimary ? ' primary' : '';
|
|
43
|
+
stdout.write(` ${index + 1}. ${file.path}${primary}\n`);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
const fileAnswer = await ask({ rl, scriptedAnswers, stdout }, 'Choose files by number, comma list, or "all": ');
|
|
47
|
+
const fileIndexes = parseSelection(fileAnswer, files.length);
|
|
48
|
+
|
|
49
|
+
for (const index of fileIndexes) {
|
|
50
|
+
selections.push({ artifactBundle: bundle, file: files[index] });
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
return selections;
|
|
55
|
+
} finally {
|
|
56
|
+
rl?.close();
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* @param {{ rl: readline.Interface | null, scriptedAnswers: string[] | null, stdout: NodeJS.WritableStream }} input
|
|
62
|
+
* @param {string} question
|
|
63
|
+
*/
|
|
64
|
+
async function ask({ rl, scriptedAnswers, stdout }, question) {
|
|
65
|
+
if (scriptedAnswers) {
|
|
66
|
+
stdout.write(question);
|
|
67
|
+
const answer = scriptedAnswers.shift();
|
|
68
|
+
|
|
69
|
+
if (answer === undefined) {
|
|
70
|
+
throw new RubrkitCliError('Interactive selection was cancelled. Use --all or selectors with --yes for CI.', {
|
|
71
|
+
code: 'interactive_selection_cancelled',
|
|
72
|
+
exitCode: 2,
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
stdout.write(`${answer}\n`);
|
|
77
|
+
return answer;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
try {
|
|
81
|
+
return await rl.question(question);
|
|
82
|
+
} catch {
|
|
83
|
+
throw new RubrkitCliError('Interactive selection was cancelled. Use --all or selectors with --yes for CI.', {
|
|
84
|
+
code: 'interactive_selection_cancelled',
|
|
85
|
+
exitCode: 2,
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* @param {NodeJS.ReadableStream} stdin
|
|
92
|
+
*/
|
|
93
|
+
async function readScriptedAnswers(stdin) {
|
|
94
|
+
let text = '';
|
|
95
|
+
|
|
96
|
+
for await (const chunk of stdin) {
|
|
97
|
+
text += chunk.toString();
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
return text
|
|
101
|
+
.split(/\r?\n/)
|
|
102
|
+
.map((line) => line.trim())
|
|
103
|
+
.filter(Boolean);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* @param {string} answer
|
|
108
|
+
* @param {number} length
|
|
109
|
+
*/
|
|
110
|
+
export function parseSelection(answer, length) {
|
|
111
|
+
const normalized = answer.trim().toLowerCase();
|
|
112
|
+
|
|
113
|
+
if (normalized === 'all' || normalized === '*') {
|
|
114
|
+
return Array.from({ length }, (_, index) => index);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const indexes = normalized
|
|
118
|
+
.split(',')
|
|
119
|
+
.map((part) => Number(part.trim()))
|
|
120
|
+
.filter((value) => Number.isInteger(value));
|
|
121
|
+
|
|
122
|
+
if (indexes.length === 0) {
|
|
123
|
+
throw new RubrkitCliError('Selection must be "all" or one or more item numbers.', {
|
|
124
|
+
code: 'invalid_selection',
|
|
125
|
+
exitCode: 2,
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const unique = [];
|
|
130
|
+
const seen = new Set();
|
|
131
|
+
|
|
132
|
+
for (const value of indexes) {
|
|
133
|
+
if (value < 1 || value > length) {
|
|
134
|
+
throw new RubrkitCliError(`Selection ${value} is out of range.`, {
|
|
135
|
+
code: 'selection_out_of_range',
|
|
136
|
+
exitCode: 2,
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const index = value - 1;
|
|
141
|
+
|
|
142
|
+
if (!seen.has(index)) {
|
|
143
|
+
unique.push(index);
|
|
144
|
+
seen.add(index);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
return unique;
|
|
149
|
+
}
|