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.
@@ -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
+ }