@coderook/cli 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,147 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.Downloader = exports.DownloadCancelled = void 0;
7
+ /**
8
+ * Bringing a project down: the file list for a version, then its bytes.
9
+ *
10
+ * The server stores each file as a content-addressed object and serves it
11
+ * decoded, so this writes plain bytes and verifies each one against the
12
+ * digest the version recorded. Anything that does not match is a failure,
13
+ * not a file quietly written wrong.
14
+ */
15
+ const node_crypto_1 = require("node:crypto");
16
+ const promises_1 = require("node:fs/promises");
17
+ const node_path_1 = __importDefault(require("node:path"));
18
+ class DownloadCancelled extends Error {
19
+ constructor() {
20
+ super("Download cancelled");
21
+ }
22
+ }
23
+ exports.DownloadCancelled = DownloadCancelled;
24
+ class Downloader {
25
+ credentials;
26
+ aborted = false;
27
+ controller = new AbortController();
28
+ constructor(credentials) {
29
+ this.credentials = credentials;
30
+ }
31
+ cancel() {
32
+ this.aborted = true;
33
+ this.controller.abort();
34
+ }
35
+ check() {
36
+ if (this.aborted)
37
+ throw new DownloadCancelled();
38
+ }
39
+ async request(route) {
40
+ const token = await this.credentials.token();
41
+ if (!token)
42
+ throw new Error("Sign in again before downloading");
43
+ const response = await fetch(`${this.credentials.origin()}${route}`, {
44
+ headers: {
45
+ authorization: `Bearer ${token}`,
46
+ "user-agent": "CodeRook/0.1",
47
+ },
48
+ signal: this.controller.signal,
49
+ });
50
+ if (!response.ok) {
51
+ const text = await response.text().catch(() => "");
52
+ let message = `${route} failed (${response.status})`;
53
+ try {
54
+ message = JSON.parse(text)?.error?.message ?? message;
55
+ }
56
+ catch {
57
+ /* the body was not JSON; the status will have to do */
58
+ }
59
+ throw new Error(message);
60
+ }
61
+ return response;
62
+ }
63
+ async versions(repositoryId) {
64
+ const body = (await (await this.request(`/v1/repositories/${repositoryId}/versions`)).json());
65
+ return (body.versions ?? []).map((row) => ({
66
+ id: String(row.id ?? ""),
67
+ sequence: Number(row.sequence ?? 0),
68
+ message: String(row.message ?? ""),
69
+ fileCount: Number(row.fileCount ?? 0),
70
+ sourceSize: Number(row.sourceSize ?? 0),
71
+ createdAt: String(row.createdAt ?? ""),
72
+ authorName: String(row.authorName ?? ""),
73
+ }));
74
+ }
75
+ async files(repositoryId, versionId) {
76
+ const body = (await (await this.request(`/v1/repositories/${repositoryId}/versions/${versionId}/files`)).json());
77
+ return (body.files ?? []).map((row) => ({
78
+ path: String(row.path ?? ""),
79
+ sha256: String(row.sha256 ?? ""),
80
+ sourceSize: Number(row.sourceSize ?? 0),
81
+ }));
82
+ }
83
+ /**
84
+ * Write a version into `destination`. Files land in a staging directory
85
+ * and are moved into place only once every one has arrived and verified,
86
+ * so a failed download never leaves a half-project behind.
87
+ */
88
+ async run(repositoryId, versionId, destination, report) {
89
+ const files = await this.files(repositoryId, versionId);
90
+ if (!files.length)
91
+ throw new Error("That version has no files");
92
+ const totalBytes = files.reduce((total, file) => total + file.sourceSize, 0);
93
+ const staging = `${destination}.incoming`;
94
+ await (0, promises_1.rm)(staging, { recursive: true, force: true });
95
+ const manifest = {};
96
+ try {
97
+ let written = 0;
98
+ let bytes = 0;
99
+ for (const file of files) {
100
+ this.check();
101
+ report({
102
+ files: written,
103
+ totalFiles: files.length,
104
+ bytes,
105
+ totalBytes,
106
+ path: file.path,
107
+ percent: Math.round((bytes / Math.max(totalBytes, 1)) * 100),
108
+ });
109
+ // A path that climbs out of the destination is refused outright.
110
+ const parts = file.path.split("/").filter(Boolean);
111
+ if (parts.some((part) => part === ".." || part.includes("\0"))) {
112
+ throw new Error(`That version contains an unsafe path: ${file.path}`);
113
+ }
114
+ const response = await this.request(`/v1/repositories/${repositoryId}/versions/${versionId}/file` +
115
+ `?path=${encodeURIComponent(file.path)}`);
116
+ const body = Buffer.from(await response.arrayBuffer());
117
+ const digest = (0, node_crypto_1.createHash)("sha256").update(body).digest("hex");
118
+ if (file.sha256 && digest !== file.sha256) {
119
+ throw new Error(`${file.path} did not arrive intact`);
120
+ }
121
+ const full = node_path_1.default.join(staging, ...parts);
122
+ await (0, promises_1.mkdir)(node_path_1.default.dirname(full), { recursive: true });
123
+ await (0, promises_1.writeFile)(full, body);
124
+ manifest[file.path] = digest;
125
+ written += 1;
126
+ bytes += body.length;
127
+ }
128
+ await (0, promises_1.mkdir)(node_path_1.default.dirname(destination), { recursive: true });
129
+ await (0, promises_1.rm)(destination, { recursive: true, force: true });
130
+ await (0, promises_1.rename)(staging, destination);
131
+ report({
132
+ files: written,
133
+ totalFiles: files.length,
134
+ bytes,
135
+ totalBytes,
136
+ path: "Done",
137
+ percent: 100,
138
+ });
139
+ return { files: written, bytes, manifest };
140
+ }
141
+ catch (error) {
142
+ await (0, promises_1.rm)(staging, { recursive: true, force: true });
143
+ throw error;
144
+ }
145
+ }
146
+ }
147
+ exports.Downloader = Downloader;
@@ -0,0 +1,172 @@
1
+ "use strict";
2
+ /** Ignore/keep rule parsing and matching, with gitignore precedence. */
3
+ Object.defineProperty(exports, "__esModule", { value: true });
4
+ exports.UNCOUNTED_DIRECTORIES = void 0;
5
+ exports.isUncounted = isUncounted;
6
+ exports.parseRules = parseRules;
7
+ exports.ruleMatches = ruleMatches;
8
+ exports.lastMatchingRule = lastMatchingRule;
9
+ exports.decide = decide;
10
+ exports.excludes = excludes;
11
+ exports.negationReachesInto = negationReachesInto;
12
+ exports.isIgnored = isIgnored;
13
+ /** Names that can never form part of a version, whatever their type. */
14
+ exports.UNCOUNTED_DIRECTORIES = new Set([
15
+ ".git",
16
+ ".hg",
17
+ ".svn",
18
+ ".coderook",
19
+ ]);
20
+ /**
21
+ * Whether an entry is version-control plumbing rather than project content.
22
+ *
23
+ * This deliberately ignores whether the entry is a directory: inside a linked
24
+ * git worktree `.git` is a *file* holding an absolute path to the real
25
+ * repository, which is both useless elsewhere and a leak of a local path.
26
+ */
27
+ function isUncounted(name) {
28
+ return exports.UNCOUNTED_DIRECTORIES.has(name);
29
+ }
30
+ function parseRules(text) {
31
+ const rules = [];
32
+ for (const line of text.split(/\r?\n/)) {
33
+ let value = line.trim();
34
+ if (!value || value.startsWith("#"))
35
+ continue;
36
+ const negated = value.startsWith("!");
37
+ if (negated)
38
+ value = value.slice(1);
39
+ if (!value)
40
+ continue;
41
+ const directoryOnly = value.endsWith("/");
42
+ value = value.replace(/\/+$/, "");
43
+ const anchored = value.startsWith("/");
44
+ value = value.replace(/^\/+/, "");
45
+ if (value)
46
+ rules.push({ pattern: value, negated, directoryOnly, anchored });
47
+ }
48
+ return rules;
49
+ }
50
+ /** Translate one glob segment into a regular expression source. */
51
+ function globToSource(pattern) {
52
+ let source = "";
53
+ for (let index = 0; index < pattern.length; index += 1) {
54
+ const character = pattern[index];
55
+ if (character === "*") {
56
+ if (pattern[index + 1] === "*") {
57
+ source += ".*";
58
+ index += 1;
59
+ // A trailing slash after ** should not require one in the subject.
60
+ if (pattern[index + 1] === "/")
61
+ index += 1;
62
+ }
63
+ else {
64
+ source += "[^/]*";
65
+ }
66
+ }
67
+ else if (character === "?") {
68
+ source += "[^/]";
69
+ }
70
+ else {
71
+ source += character.replace(/[.+^${}()|[\]\\]/g, "\\$&");
72
+ }
73
+ }
74
+ return source;
75
+ }
76
+ const cache = new Map();
77
+ function matcher(pattern) {
78
+ let expression = cache.get(pattern);
79
+ if (!expression) {
80
+ expression = new RegExp(`^${globToSource(pattern)}$`);
81
+ cache.set(pattern, expression);
82
+ }
83
+ return expression;
84
+ }
85
+ function ruleMatches(relativePath, isDirectory, rule) {
86
+ const expression = matcher(rule.pattern);
87
+ if (rule.directoryOnly) {
88
+ if (rule.anchored || rule.pattern.includes("/")) {
89
+ return (expression.test(relativePath) ||
90
+ relativePath.startsWith(`${rule.pattern.replace(/\/+$/, "")}/`));
91
+ }
92
+ const segments = relativePath.split("/");
93
+ // A file inside an ignored directory is matched by its parent segments.
94
+ const scope = isDirectory ? segments : segments.slice(0, -1);
95
+ return scope.some((segment) => expression.test(segment));
96
+ }
97
+ if (rule.anchored || rule.pattern.includes("/")) {
98
+ return expression.test(relativePath);
99
+ }
100
+ return relativePath.split("/").some((segment) => expression.test(segment));
101
+ }
102
+ /** The last matching rule wins, as it does in gitignore. */
103
+ function lastMatchingRule(relativePath, isDirectory, rules) {
104
+ let found = null;
105
+ for (const rule of rules) {
106
+ if (ruleMatches(relativePath, isDirectory, rule))
107
+ found = rule;
108
+ }
109
+ return found;
110
+ }
111
+ /**
112
+ * The layers that could speak about this path, shallowest first, so a plain
113
+ * last-match-wins scan across them gives deeper files the final word.
114
+ */
115
+ function layersFor(relativePath, layers) {
116
+ return layers
117
+ .filter((layer) => !layer.base || relativePath.startsWith(`${layer.base}/`))
118
+ .sort((left, right) => left.base.length - right.base.length);
119
+ }
120
+ /** The last rule that matched, across every layer that governs the path. */
121
+ function decide(relativePath, isDirectory, layers) {
122
+ let found = null;
123
+ for (const layer of layersFor(relativePath, layers)) {
124
+ // A nested file's patterns are written relative to its own directory.
125
+ const scoped = layer.base
126
+ ? relativePath.slice(layer.base.length + 1)
127
+ : relativePath;
128
+ const matched = lastMatchingRule(scoped, isDirectory, layer.rules);
129
+ if (matched)
130
+ found = matched;
131
+ }
132
+ return found;
133
+ }
134
+ /** Whether these layers exclude the path, negations included. */
135
+ function excludes(relativePath, isDirectory, layers) {
136
+ const rule = decide(relativePath, isDirectory, layers);
137
+ return Boolean(rule && !rule.negated);
138
+ }
139
+ /**
140
+ * Whether a negation could rescue something inside this directory.
141
+ *
142
+ * Skipping an excluded directory outright is what keeps a scan fast, but a
143
+ * `!` rule exists precisely to bring something back from inside one, so the
144
+ * subtree can only be skipped when no negation could apply below it.
145
+ */
146
+ function negationReachesInto(relativePath, layers) {
147
+ for (const layer of layers) {
148
+ // A nested file below this directory can always speak about its contents.
149
+ if (layer.base && layer.base.startsWith(`${relativePath}/`))
150
+ return true;
151
+ for (const rule of layer.rules) {
152
+ if (!rule.negated)
153
+ continue;
154
+ const prefix = layer.base ? `${layer.base}/` : "";
155
+ // A bare glob is matched against every segment, so it reaches anywhere.
156
+ if (!rule.anchored && !rule.pattern.includes("/"))
157
+ return true;
158
+ // A wildcard that spans separators can descend to any depth.
159
+ if (rule.pattern.includes("**"))
160
+ return true;
161
+ const target = `${prefix}${rule.pattern.replace(/\/+$/, "")}`;
162
+ if (target === relativePath || target.startsWith(`${relativePath}/`)) {
163
+ return true;
164
+ }
165
+ }
166
+ }
167
+ return false;
168
+ }
169
+ function isIgnored(relativePath, isDirectory, rules) {
170
+ const rule = lastMatchingRule(relativePath, isDirectory, rules);
171
+ return Boolean(rule && !rule.negated);
172
+ }