@hitch42/cli 0.1.0-beta.2
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/LICENSE +21 -0
- package/dist/hitch.js +1148 -0
- package/package.json +34 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Hitch42 Pty Ltd
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/dist/hitch.js
ADDED
|
@@ -0,0 +1,1148 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { registerHooks } from "node:module";
|
|
3
|
+
import { access, chmod, mkdir, readFile, readdir, realpath, rm, stat, writeFile } from "node:fs/promises";
|
|
4
|
+
import { basename, dirname, join, resolve } from "node:path";
|
|
5
|
+
import { pathToFileURL } from "node:url";
|
|
6
|
+
import { spawn } from "node:child_process";
|
|
7
|
+
import { homedir } from "node:os";
|
|
8
|
+
import { os } from "@orpc/server";
|
|
9
|
+
import { createCli } from "trpc-cli";
|
|
10
|
+
import { createInterface } from "node:readline/promises";
|
|
11
|
+
import { createORPCClient } from "@orpc/client";
|
|
12
|
+
import { isContractProcedure } from "@orpc/contract";
|
|
13
|
+
import { OpenAPILink } from "@orpc/openapi-client/fetch";
|
|
14
|
+
import { z } from "zod";
|
|
15
|
+
import { createClient } from "@hey-api/openapi-ts";
|
|
16
|
+
import { readFileSync } from "node:fs";
|
|
17
|
+
//#region ../shared/auth/src/store.mts
|
|
18
|
+
const DEFAULT_INSTANCE_URL = "https://app.hitch42.com";
|
|
19
|
+
var AuthenticationError = class extends Error {};
|
|
20
|
+
function isErrno$2(error, code) {
|
|
21
|
+
return error instanceof Error && "code" in error && error.code === code;
|
|
22
|
+
}
|
|
23
|
+
async function readJson(path) {
|
|
24
|
+
try {
|
|
25
|
+
return JSON.parse(await readFile(path, "utf8"));
|
|
26
|
+
} catch (error) {
|
|
27
|
+
if (isErrno$2(error, "ENOENT")) return void 0;
|
|
28
|
+
throw new Error(`Failed to read ${path}.`, { cause: error });
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
function isObject$2(value) {
|
|
32
|
+
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
33
|
+
}
|
|
34
|
+
function emptyCredentialStore() {
|
|
35
|
+
return { instances: {} };
|
|
36
|
+
}
|
|
37
|
+
async function healCredentialStore(credentialsPath, source, store, unrecognizedCount) {
|
|
38
|
+
const backupPath = `${credentialsPath}.bak`;
|
|
39
|
+
try {
|
|
40
|
+
try {
|
|
41
|
+
await writeFile(backupPath, source, {
|
|
42
|
+
flag: "wx",
|
|
43
|
+
mode: 384
|
|
44
|
+
});
|
|
45
|
+
} catch (error) {
|
|
46
|
+
if (!isErrno$2(error, "EEXIST")) throw error;
|
|
47
|
+
}
|
|
48
|
+
await chmod(backupPath, 384);
|
|
49
|
+
await writeFile(credentialsPath, `${JSON.stringify(store, null, 2)}\n`, { mode: 384 });
|
|
50
|
+
await chmod(credentialsPath, 384);
|
|
51
|
+
} catch {
|
|
52
|
+
console.error(unrecognizedCount === void 0 ? `hitch: failed to heal credential store at ${credentialsPath}; using an empty in-memory store.` : `hitch: failed to heal credential store at ${credentialsPath}; using recognized credentials in memory only.`);
|
|
53
|
+
return store;
|
|
54
|
+
}
|
|
55
|
+
console.error(unrecognizedCount === void 0 ? `hitch: reset unreadable credentials file (backup: ${backupPath}).` : unrecognizedCount === 0 ? `hitch: migrated credentials to per-organization storage (backup: ${backupPath}).` : `hitch: removed ${unrecognizedCount} unrecognized credential ${unrecognizedCount === 1 ? "entry" : "entries"} (backup: ${backupPath}).`);
|
|
56
|
+
return store;
|
|
57
|
+
}
|
|
58
|
+
async function readRawCredentialStore(options = {}) {
|
|
59
|
+
const { credentialsPath } = storePaths(options);
|
|
60
|
+
let source;
|
|
61
|
+
try {
|
|
62
|
+
source = await readFile(credentialsPath, "utf8");
|
|
63
|
+
} catch (error) {
|
|
64
|
+
if (isErrno$2(error, "ENOENT")) return emptyCredentialStore();
|
|
65
|
+
throw new Error(`Failed to read ${credentialsPath}.`, { cause: error });
|
|
66
|
+
}
|
|
67
|
+
let value;
|
|
68
|
+
try {
|
|
69
|
+
value = JSON.parse(source);
|
|
70
|
+
} catch {
|
|
71
|
+
return healCredentialStore(credentialsPath, source, emptyCredentialStore());
|
|
72
|
+
}
|
|
73
|
+
if (!isObject$2(value) || !isObject$2(value.instances)) return healCredentialStore(credentialsPath, source, emptyCredentialStore());
|
|
74
|
+
const instances = Object.fromEntries(Object.entries(value.instances).flatMap(([instanceUrl, entry]) => {
|
|
75
|
+
if (isObject$2(entry) && typeof entry.organizationId === "string") {
|
|
76
|
+
const credentials = parseCredentials(entry);
|
|
77
|
+
return credentials === void 0 ? [] : [[instanceUrl, { [credentials.orgCode]: entry }]];
|
|
78
|
+
}
|
|
79
|
+
if (!isObject$2(entry)) return [];
|
|
80
|
+
return [[instanceUrl, Object.fromEntries(Object.entries(entry).filter(([orgCode, credentials]) => parseCredentials(credentials)?.orgCode === orgCode))]];
|
|
81
|
+
}));
|
|
82
|
+
const reshaped = Object.values(value.instances).some((entry) => !isObject$2(entry) || typeof entry.organizationId === "string");
|
|
83
|
+
const countLeaves = (store) => Object.values(store).reduce((total, entry) => total + (isObject$2(entry) && typeof entry.organizationId !== "string" ? Object.keys(entry).length : 1), 0);
|
|
84
|
+
const unrecognizedCount = countLeaves(value.instances) - countLeaves(instances);
|
|
85
|
+
if (!reshaped && unrecognizedCount === 0) return value;
|
|
86
|
+
return healCredentialStore(credentialsPath, source, { instances }, unrecognizedCount);
|
|
87
|
+
}
|
|
88
|
+
function parseCredentials(value) {
|
|
89
|
+
if (!isObject$2(value) || typeof value.organizationId !== "string" || typeof value.orgCode !== "string") return;
|
|
90
|
+
const organization = {
|
|
91
|
+
organizationId: value.organizationId,
|
|
92
|
+
orgCode: value.orgCode
|
|
93
|
+
};
|
|
94
|
+
if ((value.type === void 0 || value.type === "session") && typeof value.accessToken === "string" && (value.expiresAt === void 0 || typeof value.expiresAt === "number")) return {
|
|
95
|
+
type: "session",
|
|
96
|
+
accessToken: value.accessToken,
|
|
97
|
+
...value.expiresAt === void 0 ? {} : { expiresAt: value.expiresAt },
|
|
98
|
+
...organization
|
|
99
|
+
};
|
|
100
|
+
if (value.type === "apiKey" && typeof value.apiKey === "string") return {
|
|
101
|
+
type: "apiKey",
|
|
102
|
+
apiKey: value.apiKey,
|
|
103
|
+
...organization
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
function normalizeInstanceUrl(instanceUrl) {
|
|
107
|
+
let url;
|
|
108
|
+
try {
|
|
109
|
+
url = new URL(instanceUrl);
|
|
110
|
+
} catch (error) {
|
|
111
|
+
throw new Error(`Invalid Hitch instance URL: ${instanceUrl}`, { cause: error });
|
|
112
|
+
}
|
|
113
|
+
if (url.protocol !== "http:" && url.protocol !== "https:") throw new Error(`Invalid Hitch instance URL protocol: ${url.protocol}`);
|
|
114
|
+
url.pathname = url.pathname.replace(/\/+$/, "");
|
|
115
|
+
url.search = "";
|
|
116
|
+
url.hash = "";
|
|
117
|
+
return url.toString().replace(/\/$/, "");
|
|
118
|
+
}
|
|
119
|
+
function organizationInstanceUrl(instanceUrl, orgCode) {
|
|
120
|
+
const url = new URL(normalizeInstanceUrl(instanceUrl));
|
|
121
|
+
const labels = url.hostname.split(".");
|
|
122
|
+
if (labels[0] !== "app" || labels.length < 2) return url.toString().replace(/\/$/, "");
|
|
123
|
+
url.hostname = [orgCode, ...labels.slice(1)].join(".");
|
|
124
|
+
return url.toString().replace(/\/$/, "");
|
|
125
|
+
}
|
|
126
|
+
function storePaths(options = {}) {
|
|
127
|
+
const env = options.env ?? process.env;
|
|
128
|
+
const home = options.homeDir ?? homedir();
|
|
129
|
+
const configDir = join(env.XDG_CONFIG_HOME || join(home, ".config"), "hitch");
|
|
130
|
+
return {
|
|
131
|
+
configDir,
|
|
132
|
+
cacheDir: join(env.XDG_CACHE_HOME || join(home, ".cache"), "hitch"),
|
|
133
|
+
configPath: join(configDir, "config.json"),
|
|
134
|
+
credentialsPath: join(configDir, "credentials.json")
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
async function resolveInstanceUrl(override, options = {}) {
|
|
138
|
+
if (override !== void 0) return normalizeInstanceUrl(override);
|
|
139
|
+
const envInstanceUrl = (options.env ?? process.env).HITCH_API_URL;
|
|
140
|
+
if (envInstanceUrl !== void 0 && envInstanceUrl.length > 0) return normalizeInstanceUrl(envInstanceUrl);
|
|
141
|
+
const { configPath } = storePaths(options);
|
|
142
|
+
const config = await readJson(configPath);
|
|
143
|
+
if (config === void 0) return DEFAULT_INSTANCE_URL;
|
|
144
|
+
if (!isObject$2(config) || config.instanceUrl !== void 0 && typeof config.instanceUrl !== "string") throw new Error(`${configPath}: expected { instanceUrl?: string }.`);
|
|
145
|
+
return normalizeInstanceUrl(config.instanceUrl ?? "https://app.hitch42.com");
|
|
146
|
+
}
|
|
147
|
+
async function readCredentialStore(options = {}) {
|
|
148
|
+
const store = await readRawCredentialStore(options);
|
|
149
|
+
return { instances: Object.fromEntries(Object.entries(store.instances).map(([instanceUrl, credentialsByOrg]) => [instanceUrl, Object.fromEntries(Object.entries(credentialsByOrg).flatMap(([orgCode, value]) => {
|
|
150
|
+
const credentials = parseCredentials(value);
|
|
151
|
+
return credentials === void 0 ? [] : [[orgCode, credentials]];
|
|
152
|
+
}))])) };
|
|
153
|
+
}
|
|
154
|
+
function environmentAuth(options) {
|
|
155
|
+
const apiKey = (options.env ?? process.env).HITCH_API_KEY;
|
|
156
|
+
return apiKey !== void 0 && apiKey.length > 0 ? {
|
|
157
|
+
accessToken: apiKey,
|
|
158
|
+
source: "environment"
|
|
159
|
+
} : void 0;
|
|
160
|
+
}
|
|
161
|
+
function resolvedStoredAuth(credentials) {
|
|
162
|
+
return credentials === void 0 ? void 0 : {
|
|
163
|
+
accessToken: credentials.type === "apiKey" ? credentials.apiKey : credentials.accessToken,
|
|
164
|
+
credentialType: credentials.type,
|
|
165
|
+
organizationId: credentials.organizationId,
|
|
166
|
+
orgCode: credentials.orgCode,
|
|
167
|
+
source: "store"
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
function resolveStoredAuthForOrg(credentialsByOrg, orgCode) {
|
|
171
|
+
const exact = resolvedStoredAuth(credentialsByOrg[orgCode]);
|
|
172
|
+
if (exact !== void 0) return exact;
|
|
173
|
+
const sessions = Object.values(credentialsByOrg).filter((credentials) => credentials.type === "session").filter((credentials, index, all) => all.findIndex(({ accessToken }) => accessToken === credentials.accessToken) === index);
|
|
174
|
+
if (sessions.length === 1) return resolvedStoredAuth(sessions[0]);
|
|
175
|
+
if (sessions.length > 1) throw new AuthenticationError(`Multiple sessions are stored. Run hitch login for organization "${orgCode}".`);
|
|
176
|
+
const apiKeyOrgs = Object.values(credentialsByOrg).filter((credentials) => credentials.type === "apiKey").map(({ orgCode: storedOrg }) => storedOrg).sort();
|
|
177
|
+
if (apiKeyOrgs.length > 0) throw new AuthenticationError(`Stored API ${apiKeyOrgs.length === 1 ? "key is" : "keys are"} scoped to ${apiKeyOrgs.map((storedOrg) => `"${storedOrg}"`).join(", ")}, not "${orgCode}".`);
|
|
178
|
+
}
|
|
179
|
+
async function resolveAuth(instanceUrl, options = {}, orgCode) {
|
|
180
|
+
const auth = environmentAuth(options);
|
|
181
|
+
if (auth !== void 0) return auth;
|
|
182
|
+
const normalizedUrl = normalizeInstanceUrl(instanceUrl);
|
|
183
|
+
const credentialsByOrg = (await readCredentialStore(options)).instances[normalizedUrl] ?? {};
|
|
184
|
+
const selectedOrg = orgCode ?? (options.env ?? process.env).HITCH_DEFAULT_ORG;
|
|
185
|
+
if (selectedOrg !== void 0 && selectedOrg.length > 0) return resolveStoredAuthForOrg(credentialsByOrg, selectedOrg);
|
|
186
|
+
const orgCodes = Object.keys(credentialsByOrg).sort();
|
|
187
|
+
const soleOrg = orgCodes.at(0);
|
|
188
|
+
if (soleOrg === void 0) return void 0;
|
|
189
|
+
if (orgCodes.length === 1) return resolvedStoredAuth(credentialsByOrg[soleOrg]);
|
|
190
|
+
throw new AuthenticationError(`Multiple organizations are stored for ${normalizedUrl}: ${orgCodes.join(", ")}. Use --org, a workspace hitch.org, or HITCH_DEFAULT_ORG to choose one.`);
|
|
191
|
+
}
|
|
192
|
+
function rejectEnvironmentKey(response, auth) {
|
|
193
|
+
if (auth.source === "environment" && response.status === 401) throw new AuthenticationError("HITCH_API_KEY was rejected.");
|
|
194
|
+
}
|
|
195
|
+
function bearerToken(credentials) {
|
|
196
|
+
return "apiKey" in credentials ? credentials.apiKey : credentials.accessToken;
|
|
197
|
+
}
|
|
198
|
+
async function setCredentials(instanceUrl, credentials, options = {}) {
|
|
199
|
+
const paths = storePaths(options);
|
|
200
|
+
const store = await readRawCredentialStore(options);
|
|
201
|
+
const normalizedUrl = normalizeInstanceUrl(instanceUrl);
|
|
202
|
+
const credentialsByOrg = store.instances[normalizedUrl];
|
|
203
|
+
store.instances[normalizedUrl] = {
|
|
204
|
+
...isObject$2(credentialsByOrg) ? credentialsByOrg : {},
|
|
205
|
+
[credentials.orgCode]: credentials
|
|
206
|
+
};
|
|
207
|
+
await mkdir(paths.configDir, { recursive: true });
|
|
208
|
+
await writeFile(paths.credentialsPath, `${JSON.stringify(store, null, 2)}\n`, { mode: 384 });
|
|
209
|
+
await chmod(paths.credentialsPath, 384);
|
|
210
|
+
}
|
|
211
|
+
async function deleteCredentials(instanceUrl, orgCode, options = {}) {
|
|
212
|
+
const paths = storePaths(options);
|
|
213
|
+
const store = await readRawCredentialStore(options);
|
|
214
|
+
const normalizedUrl = normalizeInstanceUrl(instanceUrl);
|
|
215
|
+
if (orgCode === void 0) delete store.instances[normalizedUrl];
|
|
216
|
+
else {
|
|
217
|
+
const credentialsByOrg = store.instances[normalizedUrl];
|
|
218
|
+
if (isObject$2(credentialsByOrg)) {
|
|
219
|
+
delete credentialsByOrg[orgCode];
|
|
220
|
+
if (Object.keys(credentialsByOrg).length === 0) delete store.instances[normalizedUrl];
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
if (Object.keys(store.instances).length === 0) {
|
|
224
|
+
await rm(paths.credentialsPath, { force: true });
|
|
225
|
+
return;
|
|
226
|
+
}
|
|
227
|
+
await mkdir(dirname(paths.credentialsPath), { recursive: true });
|
|
228
|
+
await writeFile(paths.credentialsPath, `${JSON.stringify(store, null, 2)}\n`, { mode: 384 });
|
|
229
|
+
await chmod(paths.credentialsPath, 384);
|
|
230
|
+
}
|
|
231
|
+
function instanceSlug(instanceUrl) {
|
|
232
|
+
const url = new URL(normalizeInstanceUrl(instanceUrl));
|
|
233
|
+
return `${url.protocol.slice(0, -1)}-${url.host}${url.pathname}`.replace(/[^a-zA-Z0-9.-]+/g, "-").replace(/^-+|-+$/g, "");
|
|
234
|
+
}
|
|
235
|
+
function instanceCacheDir(instanceUrl, options = {}) {
|
|
236
|
+
return join(storePaths(options).cacheDir, instanceSlug(instanceUrl));
|
|
237
|
+
}
|
|
238
|
+
function contractCacheDir(instanceUrl, organizationId, options = {}) {
|
|
239
|
+
return join(instanceCacheDir(instanceUrl, options), organizationId);
|
|
240
|
+
}
|
|
241
|
+
function adminContractCacheDir(instanceUrl, organizationId, options = {}) {
|
|
242
|
+
return join(contractCacheDir(instanceUrl, organizationId, options), "admin");
|
|
243
|
+
}
|
|
244
|
+
async function deleteInstanceCache(instanceUrl, options = {}) {
|
|
245
|
+
await rm(instanceCacheDir(instanceUrl, options), {
|
|
246
|
+
recursive: true,
|
|
247
|
+
force: true
|
|
248
|
+
});
|
|
249
|
+
}
|
|
250
|
+
//#endregion
|
|
251
|
+
//#region ../shared/auth/src/device.mts
|
|
252
|
+
const CLIENT_ID = "hitch-cli";
|
|
253
|
+
const DEVICE_GRANT = "urn:ietf:params:oauth:grant-type:device_code";
|
|
254
|
+
function defaultOpenUrl(url) {
|
|
255
|
+
try {
|
|
256
|
+
const child = spawn(process.platform === "darwin" ? "open" : "xdg-open", [url], {
|
|
257
|
+
detached: true,
|
|
258
|
+
stdio: "ignore"
|
|
259
|
+
});
|
|
260
|
+
child.on("error", () => void 0);
|
|
261
|
+
child.unref();
|
|
262
|
+
} catch {}
|
|
263
|
+
}
|
|
264
|
+
function requiredObject(value, message) {
|
|
265
|
+
if (value === null || typeof value !== "object" || Array.isArray(value)) throw new Error(message);
|
|
266
|
+
return value;
|
|
267
|
+
}
|
|
268
|
+
async function jsonResponse(response) {
|
|
269
|
+
try {
|
|
270
|
+
return requiredObject(await response.json(), "Expected a JSON object response.");
|
|
271
|
+
} catch (error) {
|
|
272
|
+
throw new Error(`Invalid JSON response from ${response.url || "Hitch"}.`, { cause: error });
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
async function postJson(fetcher, url, body) {
|
|
276
|
+
const response = await fetcher(url, {
|
|
277
|
+
method: "POST",
|
|
278
|
+
headers: { "content-type": "application/json" },
|
|
279
|
+
body: JSON.stringify(body)
|
|
280
|
+
});
|
|
281
|
+
return {
|
|
282
|
+
body: await jsonResponse(response),
|
|
283
|
+
response
|
|
284
|
+
};
|
|
285
|
+
}
|
|
286
|
+
function parseDeviceCode(value) {
|
|
287
|
+
if (typeof value.device_code !== "string" || typeof value.user_code !== "string" || typeof value.verification_uri !== "string" || typeof value.verification_uri_complete !== "string" || typeof value.expires_in !== "number" || value.interval !== void 0 && typeof value.interval !== "number") throw new AuthenticationError("Hitch returned an invalid device authorization response.");
|
|
288
|
+
return value;
|
|
289
|
+
}
|
|
290
|
+
function parseToken(value) {
|
|
291
|
+
return typeof value.access_token === "string" && typeof value.token_type === "string" ? value : void 0;
|
|
292
|
+
}
|
|
293
|
+
function parseDeviceOrganization(value) {
|
|
294
|
+
if (typeof value.organizationId !== "string" || typeof value.orgCode !== "string") throw new AuthenticationError("Hitch returned an invalid device organization response.");
|
|
295
|
+
return value;
|
|
296
|
+
}
|
|
297
|
+
function parseMe(value) {
|
|
298
|
+
const person = requiredObject(value.person, "Hitch returned an invalid identity response.");
|
|
299
|
+
if (typeof person.id !== "string" || typeof person.firstName !== "string" || typeof person.lastName !== "string" || typeof person.email !== "string" || !Array.isArray(value.memberships)) throw new Error("Hitch returned an invalid identity response.");
|
|
300
|
+
const organization = value.organization === void 0 || value.organization === null ? null : requiredObject(value.organization, "Hitch returned an invalid identity response.");
|
|
301
|
+
if (organization !== null && (typeof organization.id !== "string" || typeof organization.code !== "string")) throw new Error("Hitch returned an invalid identity response.");
|
|
302
|
+
return {
|
|
303
|
+
memberships: value.memberships.map((membership) => {
|
|
304
|
+
const item = requiredObject(membership, "Hitch returned an invalid membership.");
|
|
305
|
+
if (typeof item.orgId !== "string" || typeof item.code !== "string" || typeof item.name !== "string") throw new Error("Hitch returned an invalid membership.");
|
|
306
|
+
return item;
|
|
307
|
+
}),
|
|
308
|
+
organization,
|
|
309
|
+
person
|
|
310
|
+
};
|
|
311
|
+
}
|
|
312
|
+
async function fetchMe(fetcher, instanceUrl, auth) {
|
|
313
|
+
const response = await fetcher(`${instanceUrl}/api/v1/me`, { headers: { Authorization: `Bearer ${auth.accessToken}` } });
|
|
314
|
+
rejectEnvironmentKey(response, auth);
|
|
315
|
+
if (auth.credentialType === "apiKey" && response.status === 401) throw new AuthenticationError("API key rejected.");
|
|
316
|
+
const body = await jsonResponse(response);
|
|
317
|
+
if (!response.ok) throw new AuthenticationError(typeof body.message === "string" ? body.message : `Failed to load the signed-in identity (${response.status}).`);
|
|
318
|
+
return parseMe(body);
|
|
319
|
+
}
|
|
320
|
+
async function pollForToken(fetcher, instanceUrl, device, now, sleep) {
|
|
321
|
+
const deadline = now() + device.expires_in * 1e3;
|
|
322
|
+
let interval = (device.interval ?? 5) * 1e3;
|
|
323
|
+
while (now() < deadline) {
|
|
324
|
+
await sleep(Math.min(interval, deadline - now()));
|
|
325
|
+
if (now() >= deadline) break;
|
|
326
|
+
const { body, response } = await postJson(fetcher, `${instanceUrl}/api/auth/device/token`, {
|
|
327
|
+
grant_type: DEVICE_GRANT,
|
|
328
|
+
device_code: device.device_code,
|
|
329
|
+
client_id: CLIENT_ID
|
|
330
|
+
});
|
|
331
|
+
const token = parseToken(body);
|
|
332
|
+
if (response.ok && token !== void 0) return token;
|
|
333
|
+
switch (body.error) {
|
|
334
|
+
case "authorization_pending": break;
|
|
335
|
+
case "slow_down":
|
|
336
|
+
interval += 5e3;
|
|
337
|
+
break;
|
|
338
|
+
case "expired_token": throw new AuthenticationError("The sign-in request expired. Run hitch login again.");
|
|
339
|
+
case "access_denied": throw new AuthenticationError("Sign-in was denied.");
|
|
340
|
+
default: throw new AuthenticationError(typeof body.error_description === "string" ? body.error_description : `Sign-in failed (${response.status}).`);
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
throw new AuthenticationError("The sign-in request expired. Run hitch login again.");
|
|
344
|
+
}
|
|
345
|
+
async function authWithOrganization(fetcher, instanceUrl, auth, expectedOrg) {
|
|
346
|
+
if (expectedOrg !== void 0 && auth.credentialType === "apiKey" && auth.orgCode !== void 0 && auth.orgCode !== expectedOrg) throw new AuthenticationError(`API key is scoped to organization "${auth.orgCode}", not "${expectedOrg}".`);
|
|
347
|
+
if (auth.organizationId !== void 0 && auth.orgCode !== void 0 && (expectedOrg === void 0 || auth.orgCode === expectedOrg)) return {
|
|
348
|
+
...auth,
|
|
349
|
+
organizationId: auth.organizationId,
|
|
350
|
+
orgCode: auth.orgCode
|
|
351
|
+
};
|
|
352
|
+
let me;
|
|
353
|
+
try {
|
|
354
|
+
me = await fetchMe(fetcher, expectedOrg === void 0 ? instanceUrl : organizationInstanceUrl(instanceUrl, expectedOrg), auth);
|
|
355
|
+
} catch (error) {
|
|
356
|
+
if (expectedOrg === void 0) throw error;
|
|
357
|
+
throw new AuthenticationError(`${auth.credentialType === "apiKey" || auth.source === "environment" ? "API key" : "Session"} cannot access organization "${expectedOrg}".`, { cause: error });
|
|
358
|
+
}
|
|
359
|
+
if (me.organization === null) throw new AuthenticationError("The current identity is not scoped to a Hitch organization.");
|
|
360
|
+
if (expectedOrg !== void 0 && me.organization.code !== expectedOrg) throw new AuthenticationError(`Hitch returned organization "${me.organization.code}".`);
|
|
361
|
+
return {
|
|
362
|
+
...auth,
|
|
363
|
+
organizationId: me.organization.id,
|
|
364
|
+
orgCode: me.organization.code
|
|
365
|
+
};
|
|
366
|
+
}
|
|
367
|
+
async function loginWithDevice(instanceUrl, options = {}) {
|
|
368
|
+
const fetcher = options.fetch ?? globalThis.fetch;
|
|
369
|
+
const now = options.now ?? Date.now;
|
|
370
|
+
const { body, response } = await postJson(fetcher, `${instanceUrl}/api/auth/device/code`, { client_id: CLIENT_ID });
|
|
371
|
+
if (!response.ok) throw new AuthenticationError(typeof body.error_description === "string" ? body.error_description : `Could not start sign-in (${response.status}).`);
|
|
372
|
+
const device = parseDeviceCode(body);
|
|
373
|
+
const verificationUrl = options.expectedOrg === void 0 ? device.verification_uri_complete : `${device.verification_uri_complete}&org=${encodeURIComponent(options.expectedOrg)}`;
|
|
374
|
+
(options.print ?? console.log)(`Open ${verificationUrl} and confirm code ${device.user_code}.`);
|
|
375
|
+
if (!options.noBrowser) try {
|
|
376
|
+
await (options.openUrl ?? defaultOpenUrl)(verificationUrl);
|
|
377
|
+
} catch {}
|
|
378
|
+
const token = await pollForToken(fetcher, instanceUrl, device, now, options.sleep ?? ((milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds))));
|
|
379
|
+
const { body: organizationBody, response: organizationResponse } = await postJson(fetcher, `${instanceUrl}/api/auth/device/organization`, { device_code: device.device_code });
|
|
380
|
+
if (!organizationResponse.ok) throw new AuthenticationError("The sign-in did not resolve an organization.");
|
|
381
|
+
const organization = parseDeviceOrganization(organizationBody);
|
|
382
|
+
const me = await fetchMe(fetcher, instanceUrl, {
|
|
383
|
+
accessToken: token.access_token,
|
|
384
|
+
credentialType: "session",
|
|
385
|
+
source: "store"
|
|
386
|
+
});
|
|
387
|
+
const membership = me.memberships.find(({ orgId }) => orgId === organization.organizationId);
|
|
388
|
+
if (membership === void 0) throw new AuthenticationError("The device session is not scoped to a Hitch organization.");
|
|
389
|
+
const credentials = {
|
|
390
|
+
type: "session",
|
|
391
|
+
accessToken: token.access_token,
|
|
392
|
+
expiresAt: token.expires_in === void 0 ? void 0 : now() + token.expires_in * 1e3,
|
|
393
|
+
organizationId: organization.organizationId,
|
|
394
|
+
orgCode: organization.orgCode
|
|
395
|
+
};
|
|
396
|
+
await setCredentials(instanceUrl, credentials, options.store);
|
|
397
|
+
if (options.expectedOrg !== void 0 && organization.orgCode !== options.expectedOrg) throw new AuthenticationError(`logged in to "${organization.orgCode}" but this applet targets "${options.expectedOrg}" — confirm the right organization on the device page`);
|
|
398
|
+
return {
|
|
399
|
+
credentials,
|
|
400
|
+
me,
|
|
401
|
+
membership
|
|
402
|
+
};
|
|
403
|
+
}
|
|
404
|
+
//#endregion
|
|
405
|
+
//#region src/bridge.ts
|
|
406
|
+
async function defaultPrompt$1(message) {
|
|
407
|
+
const readline = createInterface({
|
|
408
|
+
input: process.stdin,
|
|
409
|
+
output: process.stdout
|
|
410
|
+
});
|
|
411
|
+
try {
|
|
412
|
+
return await readline.question(message);
|
|
413
|
+
} finally {
|
|
414
|
+
readline.close();
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
function cliInputSchema(schema) {
|
|
418
|
+
const jsonSchema = (io) => () => z.toJSONSchema(schema, {
|
|
419
|
+
io,
|
|
420
|
+
target: "draft-07",
|
|
421
|
+
override: ({ jsonSchema }) => {
|
|
422
|
+
if (jsonSchema.type !== "integer") return;
|
|
423
|
+
if (jsonSchema.minimum === Number.MIN_SAFE_INTEGER) delete jsonSchema.minimum;
|
|
424
|
+
if (jsonSchema.maximum === Number.MAX_SAFE_INTEGER) delete jsonSchema.maximum;
|
|
425
|
+
}
|
|
426
|
+
});
|
|
427
|
+
return { "~standard": {
|
|
428
|
+
...schema["~standard"],
|
|
429
|
+
vendor: "hitch",
|
|
430
|
+
jsonSchema: {
|
|
431
|
+
input: jsonSchema("input"),
|
|
432
|
+
output: jsonSchema("output")
|
|
433
|
+
}
|
|
434
|
+
} };
|
|
435
|
+
}
|
|
436
|
+
function unwrapOptional(schema) {
|
|
437
|
+
return schema instanceof z.ZodOptional ? {
|
|
438
|
+
required: false,
|
|
439
|
+
schema: schema.unwrap()
|
|
440
|
+
} : {
|
|
441
|
+
required: true,
|
|
442
|
+
schema
|
|
443
|
+
};
|
|
444
|
+
}
|
|
445
|
+
function flattenedInput(contract) {
|
|
446
|
+
const inputSchema = contract["~orpc"].inputSchema;
|
|
447
|
+
if (inputSchema === void 0) return {
|
|
448
|
+
schema: z.object({}),
|
|
449
|
+
sections: []
|
|
450
|
+
};
|
|
451
|
+
if (!(inputSchema instanceof z.ZodType)) throw new Error("Generated contract procedure is missing a Zod input schema.");
|
|
452
|
+
const root = unwrapOptional(inputSchema).schema;
|
|
453
|
+
if (!(root instanceof z.ZodObject)) throw new Error("Generated contract procedure input must be a detailed Zod object.");
|
|
454
|
+
const shape = {};
|
|
455
|
+
const sections = Object.entries(root.shape).map(([name, sectionSchema]) => {
|
|
456
|
+
if (name !== "body" && name !== "headers" && name !== "params" && name !== "query") throw new Error(`Unsupported generated input section: ${name}`);
|
|
457
|
+
const section = unwrapOptional(sectionSchema);
|
|
458
|
+
if (!(section.schema instanceof z.ZodObject)) throw new Error(`Generated ${name} input must be a Zod object.`);
|
|
459
|
+
Object.entries(section.schema.shape).forEach(([key, value]) => {
|
|
460
|
+
if (Object.hasOwn(shape, key)) throw new Error(`Cannot flatten duplicate generated input field: ${key}`);
|
|
461
|
+
shape[key] = value;
|
|
462
|
+
});
|
|
463
|
+
return {
|
|
464
|
+
keys: Object.keys(section.schema.shape),
|
|
465
|
+
name,
|
|
466
|
+
required: section.required
|
|
467
|
+
};
|
|
468
|
+
});
|
|
469
|
+
return {
|
|
470
|
+
schema: z.object(shape),
|
|
471
|
+
sections
|
|
472
|
+
};
|
|
473
|
+
}
|
|
474
|
+
function procedureAt(client, path) {
|
|
475
|
+
const procedure = path.reduce((value, segment) => value[segment], client);
|
|
476
|
+
if (typeof procedure !== "function") throw new Error(`Generated client procedure is missing: ${path.join(".")}`);
|
|
477
|
+
return procedure;
|
|
478
|
+
}
|
|
479
|
+
function setAtPath(router, path, value) {
|
|
480
|
+
const parent = path.slice(0, -1).reduce((node, segment) => {
|
|
481
|
+
if (!Object.hasOwn(node, segment)) {
|
|
482
|
+
const created = {};
|
|
483
|
+
node[segment] = created;
|
|
484
|
+
return created;
|
|
485
|
+
}
|
|
486
|
+
return node[segment];
|
|
487
|
+
}, router);
|
|
488
|
+
const name = path.at(-1);
|
|
489
|
+
if (name !== void 0) parent[name] = value;
|
|
490
|
+
}
|
|
491
|
+
function createBridgeRouter(contract, options) {
|
|
492
|
+
const client = createORPCClient(new OpenAPILink(contract, {
|
|
493
|
+
url: `${options.instanceUrl}/api`,
|
|
494
|
+
headers: { Authorization: `Bearer ${bearerToken(options.auth)}` },
|
|
495
|
+
fetch: async (request, init) => {
|
|
496
|
+
const response = await (options.fetch ?? globalThis.fetch)(request, init);
|
|
497
|
+
if ("source" in options.auth) rejectEnvironmentKey(response, options.auth);
|
|
498
|
+
return response;
|
|
499
|
+
}
|
|
500
|
+
}));
|
|
501
|
+
const router = {};
|
|
502
|
+
const visit = (node, path) => {
|
|
503
|
+
if (isContractProcedure(node)) {
|
|
504
|
+
const { schema, sections } = flattenedInput(node);
|
|
505
|
+
const isDelete = node["~orpc"].route.method === "DELETE";
|
|
506
|
+
if (isDelete && Object.hasOwn(schema.shape, "yes")) throw new Error("Cannot add --yes: route input already has a \"yes\" field.");
|
|
507
|
+
const inputSchema = isDelete ? schema.extend({ yes: z.boolean().default(false).describe("Skip the confirmation prompt") }) : schema;
|
|
508
|
+
const description = node["~orpc"].route.summary ?? node["~orpc"].route.description;
|
|
509
|
+
const procedure = os.meta(description ? { description } : {}).input(cliInputSchema(inputSchema)).handler(async ({ input }) => {
|
|
510
|
+
if (isDelete && !input.yes) {
|
|
511
|
+
if (options.prompt === void 0 && process.stdin.isTTY !== true) throw new Error("Refusing to delete without --yes in non-interactive mode.");
|
|
512
|
+
const values = Object.entries(input).filter(([key, value]) => key !== "yes" && value !== void 0).map(([key, value]) => `${key}=${String(value)}`).join(" ");
|
|
513
|
+
const answer = await (options.prompt ?? defaultPrompt$1)(`Confirm ${path.join(" ")}${values.length === 0 ? "" : ` ${values}`}? [y/N] `);
|
|
514
|
+
if (!["y", "yes"].includes(answer.trim().toLowerCase())) throw new Error("Aborted.");
|
|
515
|
+
}
|
|
516
|
+
const detailed = Object.fromEntries(sections.flatMap((section) => {
|
|
517
|
+
const values = Object.fromEntries(section.keys.flatMap((key) => input[key] === void 0 ? [] : [[key, input[key]]]));
|
|
518
|
+
return section.required || Object.keys(values).length > 0 ? [[section.name, values]] : [];
|
|
519
|
+
}));
|
|
520
|
+
return procedureAt(client, path)(sections.length === 0 ? void 0 : detailed);
|
|
521
|
+
});
|
|
522
|
+
setAtPath(router, path, procedure);
|
|
523
|
+
return;
|
|
524
|
+
}
|
|
525
|
+
Object.entries(node).forEach(([name, child]) => visit(child, [...path, name]));
|
|
526
|
+
};
|
|
527
|
+
visit(contract, []);
|
|
528
|
+
return router;
|
|
529
|
+
}
|
|
530
|
+
//#endregion
|
|
531
|
+
//#region src/generate.ts
|
|
532
|
+
const RECORD_ROUTE = /^\/v1\/([^/]+)(?:\/(\{id\}|query|update|aggregate))?$/;
|
|
533
|
+
const BATCH_PATH = "/v1/batch";
|
|
534
|
+
const BATCH_ITEM_PATH = "paths./v1/batch.post.requestBody.content.application/json.schema.properties.requests.items";
|
|
535
|
+
const BATCH_ID_PATH = /^\^\/v1\/([^/]+)\/\[0-9a-fA-F-\]\{36\}\$$/;
|
|
536
|
+
const VERBS = {
|
|
537
|
+
"get:": "list",
|
|
538
|
+
"post:": "create",
|
|
539
|
+
"get:{id}": "get",
|
|
540
|
+
"patch:{id}": "update",
|
|
541
|
+
"delete:{id}": "delete",
|
|
542
|
+
"post:query": "query",
|
|
543
|
+
"post:update": "updateWhere",
|
|
544
|
+
"post:aggregate": "aggregate"
|
|
545
|
+
};
|
|
546
|
+
function isObject$1(value) {
|
|
547
|
+
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
548
|
+
}
|
|
549
|
+
const COMPONENT_REF = /^#\/components\/schemas\/([^/]+)$/;
|
|
550
|
+
function resolveAllOfMember(member, schemas) {
|
|
551
|
+
if (!isObject$1(member)) return void 0;
|
|
552
|
+
if (!("$ref" in member)) return "allOf" in member ? void 0 : member;
|
|
553
|
+
if (typeof member.$ref !== "string" || Object.keys(member).length !== 1) return void 0;
|
|
554
|
+
const target = schemas[COMPONENT_REF.exec(member.$ref)?.[1] ?? ""];
|
|
555
|
+
return isObject$1(target) && !("$ref" in target) && !("allOf" in target) ? target : void 0;
|
|
556
|
+
}
|
|
557
|
+
function flattenTrivialAllOf(value, schemas) {
|
|
558
|
+
if (Array.isArray(value)) return value.map((item) => flattenTrivialAllOf(item, schemas));
|
|
559
|
+
if (!isObject$1(value)) return value;
|
|
560
|
+
const { allOf, ...siblings } = Object.fromEntries(Object.entries(value).map(([key, item]) => [key, flattenTrivialAllOf(item, schemas)]));
|
|
561
|
+
if (!Array.isArray(allOf)) return allOf === void 0 ? siblings : {
|
|
562
|
+
allOf,
|
|
563
|
+
...siblings
|
|
564
|
+
};
|
|
565
|
+
const members = allOf.map((member) => resolveAllOfMember(member, schemas));
|
|
566
|
+
const entries = [...members, siblings].flatMap((member) => Object.entries(member ?? {}));
|
|
567
|
+
return members.every(isObject$1) && new Set(entries.map(([key]) => key)).size === entries.length ? Object.fromEntries(entries) : {
|
|
568
|
+
allOf,
|
|
569
|
+
...siblings
|
|
570
|
+
};
|
|
571
|
+
}
|
|
572
|
+
function isErrno$1(error, code) {
|
|
573
|
+
return error instanceof Error && "code" in error && error.code === code;
|
|
574
|
+
}
|
|
575
|
+
async function fileExists(path) {
|
|
576
|
+
try {
|
|
577
|
+
await access(path);
|
|
578
|
+
return true;
|
|
579
|
+
} catch (error) {
|
|
580
|
+
if (isErrno$1(error, "ENOENT")) return false;
|
|
581
|
+
throw error;
|
|
582
|
+
}
|
|
583
|
+
}
|
|
584
|
+
async function addTypeScriptImportExtensions(outDir) {
|
|
585
|
+
await Promise.all((await readdir(outDir, { withFileTypes: true })).filter((entry) => entry.isFile() && entry.name.endsWith(".ts")).map(async ({ name }) => {
|
|
586
|
+
const path = resolve(outDir, name);
|
|
587
|
+
const source = await readFile(path, "utf8");
|
|
588
|
+
await writeFile(path, source.replace(/(["'])(\.\.?\/[^"']+)\1/g, (match, quote, specifier) => /\.(?:[cm]?[jt]sx?|json|node)$/i.test(specifier) ? match : `${quote}${specifier}.ts${quote}`));
|
|
589
|
+
}));
|
|
590
|
+
}
|
|
591
|
+
function recordOperation(path, method) {
|
|
592
|
+
const match = RECORD_ROUTE.exec(path);
|
|
593
|
+
if (!match || match[1] === "batch") return void 0;
|
|
594
|
+
const key = `${method.toLowerCase()}:${match.at(2) ?? ""}`;
|
|
595
|
+
if (!Object.hasOwn(VERBS, key)) return void 0;
|
|
596
|
+
return {
|
|
597
|
+
objectCode: match[1],
|
|
598
|
+
verb: VERBS[key]
|
|
599
|
+
};
|
|
600
|
+
}
|
|
601
|
+
function withRecordOperationId(path, method, operation) {
|
|
602
|
+
const record = recordOperation(path, method);
|
|
603
|
+
return record ? {
|
|
604
|
+
...operation,
|
|
605
|
+
operationId: `${record.objectCode}.${record.verb}`
|
|
606
|
+
} : void 0;
|
|
607
|
+
}
|
|
608
|
+
function validateBatchEntry(entry, index) {
|
|
609
|
+
const properties = isObject$1(entry) && isObject$1(entry.properties) ? entry.properties : void 0;
|
|
610
|
+
const method = isObject$1(properties?.method) ? properties.method.enum : void 0;
|
|
611
|
+
const path = isObject$1(properties?.path) ? properties.path : void 0;
|
|
612
|
+
const methodValue = Array.isArray(method) && method.length === 1 ? method[0] : void 0;
|
|
613
|
+
const enumPath = Array.isArray(path?.enum) && path.enum.length === 1 ? path.enum[0] : void 0;
|
|
614
|
+
if (Array.isArray(method) && method.length !== 1 || Array.isArray(path?.enum) && path.enum.length !== 1) throw new Error(`${BATCH_ITEM_PATH}.oneOf[${index}] expected a create, update, delete, or updateWhere batch entry, found ${describeValue(entry)}`);
|
|
615
|
+
const isCreate = methodValue === "POST" && typeof enumPath === "string" && /^\/v1\/([^/]+)$/.test(enumPath) && enumPath !== BATCH_PATH;
|
|
616
|
+
const isUpdateWhere = methodValue === "POST" && typeof enumPath === "string" && /^\/v1\/([^/]+)\/update$/.test(enumPath);
|
|
617
|
+
const isIdMutation = (methodValue === "PATCH" || methodValue === "DELETE") && typeof path?.pattern === "string" && BATCH_ID_PATH.test(path.pattern);
|
|
618
|
+
if (isCreate || isUpdateWhere || isIdMutation) return;
|
|
619
|
+
throw new Error(`${BATCH_ITEM_PATH}.oneOf[${index}] expected a create, update, delete, or updateWhere batch entry, found ${describeValue(entry)}`);
|
|
620
|
+
}
|
|
621
|
+
function prepareBatchOperation(operation) {
|
|
622
|
+
if (!isObject$1(operation)) throw new Error(`paths.${BATCH_PATH}.post expected an object, found ${describeValue(operation)}`);
|
|
623
|
+
const requestBody = isObject$1(operation.requestBody) ? operation.requestBody : void 0;
|
|
624
|
+
const content = isObject$1(requestBody?.content) ? requestBody.content : void 0;
|
|
625
|
+
const mediaType = isObject$1(content?.["application/json"]) ? content["application/json"] : void 0;
|
|
626
|
+
const schema = isObject$1(mediaType?.schema) ? mediaType.schema : void 0;
|
|
627
|
+
const properties = isObject$1(schema?.properties) ? schema.properties : void 0;
|
|
628
|
+
const requests = isObject$1(properties?.requests) ? properties.requests : void 0;
|
|
629
|
+
const items = isObject$1(requests?.items) ? requests.items : void 0;
|
|
630
|
+
if (items === void 0) throw new Error(`${BATCH_ITEM_PATH} expected an object, found ${describeValue(items)}`);
|
|
631
|
+
if (items.oneOf === void 0) {
|
|
632
|
+
if (items.type === "object" && Object.keys(items).length === 1) return void 0;
|
|
633
|
+
throw new Error(`${BATCH_ITEM_PATH}.oneOf expected a non-empty array, found ${describeValue(items.oneOf)}`);
|
|
634
|
+
}
|
|
635
|
+
if (!Array.isArray(items.oneOf)) throw new Error(`${BATCH_ITEM_PATH}.oneOf expected an array, found ${describeValue(items.oneOf)}`);
|
|
636
|
+
items.oneOf.forEach(validateBatchEntry);
|
|
637
|
+
if (items.oneOf.length === 0) throw new Error(`${BATCH_ITEM_PATH}.oneOf expected a non-empty array, found an empty array`);
|
|
638
|
+
return {
|
|
639
|
+
...operation,
|
|
640
|
+
operationId: "batch"
|
|
641
|
+
};
|
|
642
|
+
}
|
|
643
|
+
function prepareRecordSpec(spec) {
|
|
644
|
+
if (!isObject$1(spec) || !isObject$1(spec.paths)) throw new Error("spec: expected an OpenAPI document with paths");
|
|
645
|
+
const cloned = structuredClone(spec);
|
|
646
|
+
const prepared = flattenTrivialAllOf(cloned, isObject$1(cloned.components) && isObject$1(cloned.components.schemas) ? cloned.components.schemas : {});
|
|
647
|
+
prepared.paths = Object.fromEntries(Object.entries(prepared.paths).flatMap(([path, pathItem]) => {
|
|
648
|
+
if (path === BATCH_PATH) {
|
|
649
|
+
if (!isObject$1(pathItem) || pathItem.post === void 0) return [];
|
|
650
|
+
const operation = prepareBatchOperation(pathItem.post);
|
|
651
|
+
return operation === void 0 ? [] : [[path, { post: operation }]];
|
|
652
|
+
}
|
|
653
|
+
const operations = Object.entries(pathItem).flatMap(([method, operation]) => {
|
|
654
|
+
const prepared = withRecordOperationId(path, method, operation);
|
|
655
|
+
return prepared ? [[method, prepared]] : [];
|
|
656
|
+
});
|
|
657
|
+
return operations.length > 0 ? [[path, Object.fromEntries(operations)]] : [];
|
|
658
|
+
}));
|
|
659
|
+
return prepared;
|
|
660
|
+
}
|
|
661
|
+
function describeValue(value) {
|
|
662
|
+
if (value === void 0) return "undefined";
|
|
663
|
+
if (value === null) return "null";
|
|
664
|
+
if (Array.isArray(value)) return "an array";
|
|
665
|
+
if (typeof value === "object") return "an object";
|
|
666
|
+
return `${typeof value} ${JSON.stringify(value)}`;
|
|
667
|
+
}
|
|
668
|
+
async function generateContract(spec, outDir) {
|
|
669
|
+
await mkdir(outDir, { recursive: true });
|
|
670
|
+
const paths = spec !== null && typeof spec === "object" && "paths" in spec ? spec.paths : void 0;
|
|
671
|
+
if (!(paths !== null && typeof paths === "object" && !Array.isArray(paths) && Object.keys(paths).length === 0)) {
|
|
672
|
+
await createClient({
|
|
673
|
+
input: spec,
|
|
674
|
+
output: outDir,
|
|
675
|
+
plugins: ["zod", {
|
|
676
|
+
name: "orpc",
|
|
677
|
+
contracts: {
|
|
678
|
+
strategy: "single",
|
|
679
|
+
nesting: "operationId"
|
|
680
|
+
}
|
|
681
|
+
}]
|
|
682
|
+
});
|
|
683
|
+
await addTypeScriptImportExtensions(outDir);
|
|
684
|
+
}
|
|
685
|
+
const orpcPath = resolve(outDir, "orpc.gen.ts");
|
|
686
|
+
if (!await fileExists(orpcPath)) await Promise.all([writeFile(orpcPath, "// Generated by applet generate — no operations in spec.\nexport const contract = {} as const;\n"), fileExists(resolve(outDir, "zod.gen.ts")).then((exists) => exists ? void 0 : writeFile(resolve(outDir, "zod.gen.ts"), "// Generated by applet generate — no schemas in spec.\nexport {};\n"))]);
|
|
687
|
+
}
|
|
688
|
+
//#endregion
|
|
689
|
+
//#region src/cache.ts
|
|
690
|
+
const HTTP_METHODS = /* @__PURE__ */ new Set([
|
|
691
|
+
"delete",
|
|
692
|
+
"get",
|
|
693
|
+
"head",
|
|
694
|
+
"options",
|
|
695
|
+
"patch",
|
|
696
|
+
"post",
|
|
697
|
+
"put",
|
|
698
|
+
"trace"
|
|
699
|
+
]);
|
|
700
|
+
const WRITE_VERBS = {
|
|
701
|
+
POST: "create",
|
|
702
|
+
PATCH: "update",
|
|
703
|
+
PUT: "set",
|
|
704
|
+
DELETE: "delete"
|
|
705
|
+
};
|
|
706
|
+
const camelSegment = (segment) => segment.replace(/-(\w)/g, (_, letter) => letter.toUpperCase());
|
|
707
|
+
function adminOperationId(method, path) {
|
|
708
|
+
const segments = path.replace(/^\/admin\/v1\//, "").split("/");
|
|
709
|
+
const statics = segments.filter((segment) => !segment.startsWith("{"));
|
|
710
|
+
if (statics.length === 0) throw new Error(`Admin route ${method} ${path} has no resource segment.`);
|
|
711
|
+
const collection = !(segments.at(-1)?.startsWith("{") === true) && statics.at(-1)?.endsWith("s") === true;
|
|
712
|
+
const verb = method === "GET" ? collection ? "list" : "get" : WRITE_VERBS[method] ?? method.toLowerCase();
|
|
713
|
+
return [...statics.map(camelSegment), verb].join(".");
|
|
714
|
+
}
|
|
715
|
+
function isObject(value) {
|
|
716
|
+
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
717
|
+
}
|
|
718
|
+
function isUnknownJsonSchema(value) {
|
|
719
|
+
if (!isObject(value) || !Array.isArray(value.anyOf) || value.anyOf.length !== 2) return false;
|
|
720
|
+
const [anything, impossible] = value.anyOf;
|
|
721
|
+
return isObject(anything) && Object.keys(anything).length === 0 && isObject(impossible) && isObject(impossible.not) && Object.keys(impossible.not).length === 0;
|
|
722
|
+
}
|
|
723
|
+
function normalizeUnknownResponseSchemas(operation) {
|
|
724
|
+
if (!isObject(operation.responses)) return operation;
|
|
725
|
+
return {
|
|
726
|
+
...operation,
|
|
727
|
+
responses: Object.fromEntries(Object.entries(operation.responses).map(([status, response]) => {
|
|
728
|
+
if (!isObject(response) || !isObject(response.content)) return [status, response];
|
|
729
|
+
return [status, {
|
|
730
|
+
...response,
|
|
731
|
+
content: Object.fromEntries(Object.entries(response.content).map(([mediaType, media]) => isObject(media) && isUnknownJsonSchema(media.schema) ? [mediaType, {
|
|
732
|
+
...media,
|
|
733
|
+
schema: { type: [
|
|
734
|
+
"array",
|
|
735
|
+
"boolean",
|
|
736
|
+
"integer",
|
|
737
|
+
"null",
|
|
738
|
+
"number",
|
|
739
|
+
"object",
|
|
740
|
+
"string"
|
|
741
|
+
] }
|
|
742
|
+
}] : [mediaType, media]))
|
|
743
|
+
}];
|
|
744
|
+
}))
|
|
745
|
+
};
|
|
746
|
+
}
|
|
747
|
+
function prepareAdminSpec(spec) {
|
|
748
|
+
if (!isObject(spec) || !isObject(spec.paths)) throw new Error("spec: expected an OpenAPI document with paths");
|
|
749
|
+
const prepared = structuredClone(spec);
|
|
750
|
+
const derivedOperationIds = /* @__PURE__ */ new Set();
|
|
751
|
+
prepared.paths = Object.fromEntries(Object.entries(prepared.paths).flatMap(([path, value]) => {
|
|
752
|
+
if (path === "/admin/v1/openapi.json") return [];
|
|
753
|
+
if (!isObject(value)) throw new Error(`spec: expected an OpenAPI path item at ${path}`);
|
|
754
|
+
const pathItem = Object.fromEntries(Object.entries(value).map(([method, operation]) => {
|
|
755
|
+
if (!HTTP_METHODS.has(method.toLowerCase()) || !isObject(operation)) return [method, operation];
|
|
756
|
+
const operationId = adminOperationId(method.toUpperCase(), path);
|
|
757
|
+
if (derivedOperationIds.has(operationId)) throw new Error(`Admin route naming produced duplicate command "${operationId}".`);
|
|
758
|
+
derivedOperationIds.add(operationId);
|
|
759
|
+
return [method, normalizeUnknownResponseSchemas({
|
|
760
|
+
...operation,
|
|
761
|
+
operationId
|
|
762
|
+
})];
|
|
763
|
+
}));
|
|
764
|
+
return Object.keys(pathItem).some((method) => HTTP_METHODS.has(method.toLowerCase())) ? [[path, pathItem]] : [];
|
|
765
|
+
}));
|
|
766
|
+
return prepared;
|
|
767
|
+
}
|
|
768
|
+
function objectCount(spec) {
|
|
769
|
+
if (spec === null || typeof spec !== "object" || !("paths" in spec)) return 0;
|
|
770
|
+
const paths = spec.paths;
|
|
771
|
+
if (paths === null || typeof paths !== "object") return 0;
|
|
772
|
+
return new Set(Object.entries(paths).flatMap(([path, item]) => item !== null && typeof item === "object" ? Object.keys(item).flatMap((method) => {
|
|
773
|
+
const operation = recordOperation(path, method);
|
|
774
|
+
return operation ? [operation.objectCode] : [];
|
|
775
|
+
}) : [])).size;
|
|
776
|
+
}
|
|
777
|
+
async function syncContractCache(instanceUrl, credentials, options = {}) {
|
|
778
|
+
if (credentials.organizationId === void 0) throw new Error("Stored credentials do not include an organization. Run hitch login again.");
|
|
779
|
+
const response = await (options.fetch ?? globalThis.fetch)(`${instanceUrl}/api/v1/openapi.json`, { headers: { Authorization: `Bearer ${bearerToken(credentials)}` } });
|
|
780
|
+
if ("source" in credentials) rejectEnvironmentKey(response, credentials);
|
|
781
|
+
if (!response.ok) throw new Error(`Failed to fetch the organization contract (${response.status} ${response.statusText}).`);
|
|
782
|
+
const prepared = prepareRecordSpec(await response.json());
|
|
783
|
+
const count = objectCount(prepared);
|
|
784
|
+
const outDir = contractCacheDir(instanceUrl, credentials.organizationId, options.store);
|
|
785
|
+
await mkdir(outDir, { recursive: true });
|
|
786
|
+
await Promise.all((await readdir(outDir)).flatMap((name) => name === "admin" ? [] : [rm(resolve(outDir, name), {
|
|
787
|
+
recursive: true,
|
|
788
|
+
force: true
|
|
789
|
+
})]));
|
|
790
|
+
await generateContract(prepared, outDir);
|
|
791
|
+
await writeFile(resolve(outDir, "cli-meta.json"), `${JSON.stringify({
|
|
792
|
+
organizationId: credentials.organizationId,
|
|
793
|
+
objectCount: count
|
|
794
|
+
}, null, 2)}\n`);
|
|
795
|
+
return count;
|
|
796
|
+
}
|
|
797
|
+
async function syncAdminContractCache(instanceUrl, credentials, options = {}) {
|
|
798
|
+
if (credentials.organizationId === void 0) throw new Error("Stored credentials do not include an organization. Run hitch login again.");
|
|
799
|
+
const outDir = adminContractCacheDir(instanceUrl, credentials.organizationId, options.store);
|
|
800
|
+
let response;
|
|
801
|
+
try {
|
|
802
|
+
response = await (options.fetch ?? globalThis.fetch)(`${instanceUrl}/api/admin/v1/openapi.json`, { headers: { Authorization: `Bearer ${bearerToken(credentials)}` } });
|
|
803
|
+
if ("source" in credentials) rejectEnvironmentKey(response, credentials);
|
|
804
|
+
} catch (error) {
|
|
805
|
+
if (error instanceof AuthenticationError) throw error;
|
|
806
|
+
(options.warn ?? console.error)(`Warning: failed to probe admin commands: ${error instanceof Error ? error.message : String(error)}`);
|
|
807
|
+
return false;
|
|
808
|
+
}
|
|
809
|
+
if (response.status === 401 || response.status === 403) {
|
|
810
|
+
await rm(outDir, {
|
|
811
|
+
recursive: true,
|
|
812
|
+
force: true
|
|
813
|
+
});
|
|
814
|
+
return false;
|
|
815
|
+
}
|
|
816
|
+
if (!response.ok) {
|
|
817
|
+
(options.warn ?? console.error)(`Warning: failed to probe admin commands (${response.status} ${response.statusText}).`);
|
|
818
|
+
return false;
|
|
819
|
+
}
|
|
820
|
+
const prepared = prepareAdminSpec(await response.json());
|
|
821
|
+
await rm(outDir, {
|
|
822
|
+
recursive: true,
|
|
823
|
+
force: true
|
|
824
|
+
});
|
|
825
|
+
await mkdir(outDir, { recursive: true });
|
|
826
|
+
await generateContract(prepared, outDir);
|
|
827
|
+
return true;
|
|
828
|
+
}
|
|
829
|
+
//#endregion
|
|
830
|
+
//#region src/commands.ts
|
|
831
|
+
var NotLoggedInError = class extends Error {};
|
|
832
|
+
async function defaultPrompt(message) {
|
|
833
|
+
const readline = createInterface({
|
|
834
|
+
input: process.stdin,
|
|
835
|
+
output: process.stdout
|
|
836
|
+
});
|
|
837
|
+
try {
|
|
838
|
+
return await readline.question(message);
|
|
839
|
+
} finally {
|
|
840
|
+
readline.close();
|
|
841
|
+
}
|
|
842
|
+
}
|
|
843
|
+
function rejectEnvironmentCredentialManagement(options = {}) {
|
|
844
|
+
if ((options.env ?? process.env).HITCH_API_KEY !== void 0) throw new AuthenticationError("HITCH_API_KEY is set — unset it to manage stored logins.");
|
|
845
|
+
}
|
|
846
|
+
function requireAuth(auth) {
|
|
847
|
+
if (auth === void 0) throw new NotLoggedInError("Not logged in. Run hitch login.");
|
|
848
|
+
return auth;
|
|
849
|
+
}
|
|
850
|
+
function createStaticRouter(activeInstanceUrl, dependencies = {}, selectedOrg) {
|
|
851
|
+
const fetcher = dependencies.fetch ?? globalThis.fetch;
|
|
852
|
+
const now = dependencies.now ?? Date.now;
|
|
853
|
+
const print = dependencies.print ?? console.log;
|
|
854
|
+
const warn = dependencies.warn ?? console.error;
|
|
855
|
+
return os.router({
|
|
856
|
+
login: os.meta({ description: "Sign in to a Hitch organization." }).input(z.object({
|
|
857
|
+
apiKey: z.union([z.string(), z.boolean()]).default(false).describe("Store an API key, prompting when no value is given"),
|
|
858
|
+
noBrowser: z.boolean().default(false).describe("Do not open a browser"),
|
|
859
|
+
instance: z.string().optional().describe("Hitch instance URL")
|
|
860
|
+
})).handler(async ({ input }) => {
|
|
861
|
+
rejectEnvironmentCredentialManagement(dependencies.store);
|
|
862
|
+
if (input.apiKey !== false && input.noBrowser) throw new AuthenticationError("--api-key and --no-browser cannot be used together.");
|
|
863
|
+
const instanceUrl = input.instance === void 0 ? activeInstanceUrl : await resolveInstanceUrl(input.instance, dependencies.store);
|
|
864
|
+
let me;
|
|
865
|
+
let membership;
|
|
866
|
+
let storedCredentials;
|
|
867
|
+
if (input.apiKey !== false) {
|
|
868
|
+
const apiKey = typeof input.apiKey === "string" ? input.apiKey : await (dependencies.prompt ?? defaultPrompt)("Paste your API key: ");
|
|
869
|
+
if (apiKey.length === 0) throw new AuthenticationError("API key cannot be empty.");
|
|
870
|
+
me = await fetchMe(fetcher, instanceUrl, {
|
|
871
|
+
accessToken: apiKey,
|
|
872
|
+
credentialType: "apiKey",
|
|
873
|
+
source: "store"
|
|
874
|
+
});
|
|
875
|
+
const organization = me.organization;
|
|
876
|
+
if (organization === null) throw new AuthenticationError("The API key is not scoped to a Hitch organization.");
|
|
877
|
+
const apiKeyMembership = me.memberships.find(({ orgId }) => orgId === organization.id);
|
|
878
|
+
if (apiKeyMembership === void 0) throw new AuthenticationError("The API key is not scoped to a Hitch organization.");
|
|
879
|
+
if (selectedOrg !== void 0 && organization.code !== selectedOrg) throw new AuthenticationError(`API key is scoped to organization "${organization.code}", not "${selectedOrg}".`);
|
|
880
|
+
membership = apiKeyMembership;
|
|
881
|
+
storedCredentials = {
|
|
882
|
+
type: "apiKey",
|
|
883
|
+
apiKey,
|
|
884
|
+
organizationId: organization.id,
|
|
885
|
+
orgCode: organization.code
|
|
886
|
+
};
|
|
887
|
+
await setCredentials(instanceUrl, storedCredentials, dependencies.store);
|
|
888
|
+
} else {
|
|
889
|
+
const login = await loginWithDevice(instanceUrl, {
|
|
890
|
+
expectedOrg: selectedOrg,
|
|
891
|
+
fetch: fetcher,
|
|
892
|
+
noBrowser: input.noBrowser,
|
|
893
|
+
now,
|
|
894
|
+
openUrl: dependencies.openUrl,
|
|
895
|
+
print,
|
|
896
|
+
sleep: dependencies.sleep,
|
|
897
|
+
store: dependencies.store
|
|
898
|
+
});
|
|
899
|
+
me = login.me;
|
|
900
|
+
membership = login.membership;
|
|
901
|
+
storedCredentials = login.credentials;
|
|
902
|
+
}
|
|
903
|
+
const organizationUrl = organizationInstanceUrl(instanceUrl, storedCredentials.orgCode);
|
|
904
|
+
await syncContractCache(organizationUrl, storedCredentials, {
|
|
905
|
+
fetch: fetcher,
|
|
906
|
+
store: dependencies.store
|
|
907
|
+
});
|
|
908
|
+
await syncAdminContractCache(organizationUrl, storedCredentials, {
|
|
909
|
+
fetch: fetcher,
|
|
910
|
+
store: dependencies.store,
|
|
911
|
+
warn
|
|
912
|
+
});
|
|
913
|
+
print(`Signed in as ${me.person.email} to ${membership.name}${storedCredentials.type === "apiKey" ? " (API key)" : ""}.`);
|
|
914
|
+
}),
|
|
915
|
+
logout: os.meta({ description: "Sign out and remove locally stored credentials." }).input(z.object({})).handler(async () => {
|
|
916
|
+
rejectEnvironmentCredentialManagement(dependencies.store);
|
|
917
|
+
const credentials = Object.values((await readCredentialStore(dependencies.store)).instances[normalizeInstanceUrl(activeInstanceUrl)] ?? {});
|
|
918
|
+
await Promise.all(credentials.filter((credential) => credential.type === "session").map(async (credential) => {
|
|
919
|
+
try {
|
|
920
|
+
const response = await fetcher(`${activeInstanceUrl}/api/auth/sign-out`, {
|
|
921
|
+
method: "POST",
|
|
922
|
+
headers: { Authorization: `Bearer ${bearerToken(credential)}` }
|
|
923
|
+
});
|
|
924
|
+
if (!response.ok) warn(`Warning: server sign-out failed (${response.status}).`);
|
|
925
|
+
} catch (error) {
|
|
926
|
+
warn(`Warning: server sign-out failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
927
|
+
}
|
|
928
|
+
}));
|
|
929
|
+
const cacheUrls = [activeInstanceUrl, ...credentials.map((credential) => organizationInstanceUrl(activeInstanceUrl, credential.orgCode))];
|
|
930
|
+
await Promise.all([deleteCredentials(activeInstanceUrl, void 0, dependencies.store), ...[...new Set(cacheUrls)].map((url) => deleteInstanceCache(url, dependencies.store))]);
|
|
931
|
+
print(`Logged out of ${activeInstanceUrl}.`);
|
|
932
|
+
}),
|
|
933
|
+
openapi: os.meta({ description: "Print the organization's OpenAPI spec as JSON (used by applet generate)" }).input(z.object({})).handler(async () => {
|
|
934
|
+
const auth = await authWithOrganization(fetcher, activeInstanceUrl, requireAuth(await resolveAuth(activeInstanceUrl, dependencies.store, selectedOrg)), selectedOrg);
|
|
935
|
+
const response = await fetcher(`${organizationInstanceUrl(activeInstanceUrl, auth.orgCode)}/api/v1/openapi.json`, { headers: { Authorization: `Bearer ${auth.accessToken}` } });
|
|
936
|
+
rejectEnvironmentKey(response, auth);
|
|
937
|
+
if (!response.ok) throw new AuthenticationError(`Failed to load the organization's OpenAPI spec (${response.status}).`);
|
|
938
|
+
process.stdout.write(`${JSON.stringify(await response.json(), null, 2)}\n`);
|
|
939
|
+
}),
|
|
940
|
+
whoami: os.meta({ description: "Show the current Hitch identity and organization." }).input(z.object({})).handler(async () => {
|
|
941
|
+
const auth = await authWithOrganization(fetcher, activeInstanceUrl, requireAuth(await resolveAuth(activeInstanceUrl, dependencies.store, selectedOrg)), selectedOrg);
|
|
942
|
+
const me = await fetchMe(fetcher, organizationInstanceUrl(activeInstanceUrl, auth.orgCode), auth);
|
|
943
|
+
const membership = me.memberships.find(({ orgId }) => orgId === auth.organizationId);
|
|
944
|
+
if (membership === void 0) throw new AuthenticationError(`The current identity is not a member of organization "${auth.orgCode}".`);
|
|
945
|
+
return {
|
|
946
|
+
id: me.person.id,
|
|
947
|
+
email: me.person.email,
|
|
948
|
+
name: `${me.person.firstName} ${me.person.lastName}`.trim(),
|
|
949
|
+
org: membership.name,
|
|
950
|
+
orgId: auth.organizationId
|
|
951
|
+
};
|
|
952
|
+
}),
|
|
953
|
+
sync: os.meta({ description: "Refresh the cached commands for the current organization." }).input(z.object({})).handler(async () => {
|
|
954
|
+
const auth = await authWithOrganization(fetcher, activeInstanceUrl, requireAuth(await resolveAuth(activeInstanceUrl, dependencies.store, selectedOrg)), selectedOrg);
|
|
955
|
+
const organizationUrl = organizationInstanceUrl(activeInstanceUrl, auth.orgCode);
|
|
956
|
+
const count = await syncContractCache(organizationUrl, auth, {
|
|
957
|
+
fetch: fetcher,
|
|
958
|
+
store: dependencies.store
|
|
959
|
+
});
|
|
960
|
+
await syncAdminContractCache(organizationUrl, auth, {
|
|
961
|
+
fetch: fetcher,
|
|
962
|
+
store: dependencies.store,
|
|
963
|
+
warn
|
|
964
|
+
});
|
|
965
|
+
print(`Synced ${count} object${count === 1 ? "" : "s"}.`);
|
|
966
|
+
})
|
|
967
|
+
});
|
|
968
|
+
}
|
|
969
|
+
//#endregion
|
|
970
|
+
//#region src/organization.ts
|
|
971
|
+
const ORG_CODE_PATTERN = /^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/;
|
|
972
|
+
function readOrg(dir) {
|
|
973
|
+
const path = resolve(dir, "package.json");
|
|
974
|
+
let source;
|
|
975
|
+
try {
|
|
976
|
+
source = readFileSync(path, "utf8");
|
|
977
|
+
} catch (error) {
|
|
978
|
+
if (error instanceof Error && "code" in error && error.code === "ENOENT") return void 0;
|
|
979
|
+
throw error;
|
|
980
|
+
}
|
|
981
|
+
let packageJson;
|
|
982
|
+
try {
|
|
983
|
+
packageJson = JSON.parse(source);
|
|
984
|
+
} catch (error) {
|
|
985
|
+
throw new Error(`Invalid JSON in ${path}.`, { cause: error });
|
|
986
|
+
}
|
|
987
|
+
if (packageJson === null || typeof packageJson !== "object" || !("hitch" in packageJson)) return;
|
|
988
|
+
const hitch = packageJson.hitch;
|
|
989
|
+
if (hitch === null || typeof hitch !== "object" || !("org" in hitch) || typeof hitch.org !== "string" || !ORG_CODE_PATTERN.test(hitch.org)) throw new Error(`Invalid "hitch.org" in ${path}.`);
|
|
990
|
+
return hitch.org;
|
|
991
|
+
}
|
|
992
|
+
function readWorkspaceOrg(cwd) {
|
|
993
|
+
const localOrg = readOrg(cwd);
|
|
994
|
+
if (localOrg !== void 0) return localOrg;
|
|
995
|
+
const appletsDir = dirname(cwd);
|
|
996
|
+
return basename(appletsDir) === "applets" ? readOrg(dirname(appletsDir)) : void 0;
|
|
997
|
+
}
|
|
998
|
+
function validateOrg(orgCode, source) {
|
|
999
|
+
if (!ORG_CODE_PATTERN.test(orgCode)) throw new Error(`Invalid organization in ${source}.`);
|
|
1000
|
+
return orgCode;
|
|
1001
|
+
}
|
|
1002
|
+
function resolveOrganization(explicitOrg, cwd, env) {
|
|
1003
|
+
const cliOrg = explicitOrg === void 0 ? void 0 : validateOrg(explicitOrg, "--org");
|
|
1004
|
+
const workspaceOrg = readWorkspaceOrg(cwd);
|
|
1005
|
+
if (cliOrg !== void 0 && workspaceOrg !== void 0 && cliOrg !== workspaceOrg) throw new Error(`--org "${cliOrg}" does not match workspace organization "${workspaceOrg}".`);
|
|
1006
|
+
if (cliOrg !== void 0 || workspaceOrg !== void 0) return cliOrg ?? workspaceOrg;
|
|
1007
|
+
const defaultOrg = env.HITCH_DEFAULT_ORG;
|
|
1008
|
+
return defaultOrg === void 0 || defaultOrg.length === 0 ? void 0 : validateOrg(defaultOrg, "HITCH_DEFAULT_ORG");
|
|
1009
|
+
}
|
|
1010
|
+
function extractOrganizationArg(argv) {
|
|
1011
|
+
let orgCode;
|
|
1012
|
+
const remaining = [];
|
|
1013
|
+
let acceptsOptions = true;
|
|
1014
|
+
for (let index = 0; index < argv.length; index += 1) {
|
|
1015
|
+
const value = argv[index];
|
|
1016
|
+
if (value === "--") acceptsOptions = false;
|
|
1017
|
+
if (!acceptsOptions || value !== "--org" && !value.startsWith("--org=")) {
|
|
1018
|
+
remaining.push(value);
|
|
1019
|
+
continue;
|
|
1020
|
+
}
|
|
1021
|
+
if (orgCode !== void 0) throw new Error("Use --org only once.");
|
|
1022
|
+
const nextValue = value === "--org" ? argv.at(index + 1) : value.slice(6);
|
|
1023
|
+
if (nextValue === void 0 || nextValue.length === 0 || nextValue.startsWith("-")) throw new Error("--org requires an organization code.");
|
|
1024
|
+
orgCode = nextValue;
|
|
1025
|
+
if (value === "--org") index += 1;
|
|
1026
|
+
}
|
|
1027
|
+
return {
|
|
1028
|
+
argv: remaining,
|
|
1029
|
+
...orgCode === void 0 ? {} : { orgCode }
|
|
1030
|
+
};
|
|
1031
|
+
}
|
|
1032
|
+
//#endregion
|
|
1033
|
+
//#region src/cli.ts
|
|
1034
|
+
function isErrno(error, code) {
|
|
1035
|
+
return error instanceof Error && "code" in error && error.code === code;
|
|
1036
|
+
}
|
|
1037
|
+
const cacheDirectoryUrls = /* @__PURE__ */ new Set();
|
|
1038
|
+
let cacheResolveHookRegistered = false;
|
|
1039
|
+
async function resolveCacheImportsAgainstCli(contractPath) {
|
|
1040
|
+
cacheDirectoryUrls.add(`${pathToFileURL(await realpath(dirname(contractPath))).href}/`);
|
|
1041
|
+
if (cacheResolveHookRegistered) return;
|
|
1042
|
+
cacheResolveHookRegistered = true;
|
|
1043
|
+
registerHooks({ resolve(specifier, context, nextResolve) {
|
|
1044
|
+
const importer = context.parentURL;
|
|
1045
|
+
const isBare = !specifier.startsWith(".") && !URL.canParse(specifier);
|
|
1046
|
+
if (importer !== void 0 && isBare && [...cacheDirectoryUrls].some((directory) => importer.startsWith(directory))) return {
|
|
1047
|
+
url: import.meta.resolve(specifier),
|
|
1048
|
+
shortCircuit: true
|
|
1049
|
+
};
|
|
1050
|
+
return nextResolve(specifier, context);
|
|
1051
|
+
} });
|
|
1052
|
+
}
|
|
1053
|
+
async function loadContract(path, warn) {
|
|
1054
|
+
let modified;
|
|
1055
|
+
try {
|
|
1056
|
+
modified = (await stat(path)).mtimeMs;
|
|
1057
|
+
} catch (error) {
|
|
1058
|
+
if (isErrno(error, "ENOENT")) return void 0;
|
|
1059
|
+
throw error;
|
|
1060
|
+
}
|
|
1061
|
+
await resolveCacheImportsAgainstCli(path);
|
|
1062
|
+
let module;
|
|
1063
|
+
try {
|
|
1064
|
+
module = await import(`${pathToFileURL(path).href}?mtime=${modified}`);
|
|
1065
|
+
} catch (error) {
|
|
1066
|
+
if (!isErrno(error, "ERR_MODULE_NOT_FOUND")) throw error;
|
|
1067
|
+
warn(`hitch: the cached commands at ${path} no longer load — run hitch sync to refresh them.`);
|
|
1068
|
+
return;
|
|
1069
|
+
}
|
|
1070
|
+
if (module.contract === null || typeof module.contract !== "object") throw new Error(`${path}: generated contract export is missing.`);
|
|
1071
|
+
return module.contract;
|
|
1072
|
+
}
|
|
1073
|
+
async function buildRouter(dependencies = {}, options = {}) {
|
|
1074
|
+
const instanceUrl = await resolveInstanceUrl(void 0, dependencies.store);
|
|
1075
|
+
const selectedOrg = resolveOrganization(options.orgCode, process.cwd(), dependencies.store?.env ?? process.env);
|
|
1076
|
+
const staticRouter = createStaticRouter(instanceUrl, dependencies, selectedOrg);
|
|
1077
|
+
if (options.staticOnly) return staticRouter;
|
|
1078
|
+
let resolved;
|
|
1079
|
+
try {
|
|
1080
|
+
resolved = await resolveAuth(instanceUrl, dependencies.store, selectedOrg);
|
|
1081
|
+
} catch (error) {
|
|
1082
|
+
(dependencies.warn ?? console.error)(`hitch: ${error instanceof Error ? error.message : String(error)}`);
|
|
1083
|
+
return staticRouter;
|
|
1084
|
+
}
|
|
1085
|
+
if (resolved === void 0) return staticRouter;
|
|
1086
|
+
const auth = await authWithOrganization(dependencies.fetch ?? globalThis.fetch, instanceUrl, resolved, selectedOrg);
|
|
1087
|
+
const warn = dependencies.warn ?? console.error;
|
|
1088
|
+
const organizationUrl = organizationInstanceUrl(instanceUrl, auth.orgCode);
|
|
1089
|
+
const [contract, adminContract] = await Promise.all([loadContract(resolve(contractCacheDir(organizationUrl, auth.organizationId, dependencies.store), "orpc.gen.ts"), warn), loadContract(resolve(adminContractCacheDir(organizationUrl, auth.organizationId, dependencies.store), "orpc.gen.ts"), warn)]);
|
|
1090
|
+
const bridgeOptions = {
|
|
1091
|
+
instanceUrl: organizationUrl,
|
|
1092
|
+
auth,
|
|
1093
|
+
fetch: dependencies.fetch,
|
|
1094
|
+
prompt: dependencies.prompt
|
|
1095
|
+
};
|
|
1096
|
+
const dynamicRouter = contract === void 0 ? {} : createBridgeRouter(contract, bridgeOptions);
|
|
1097
|
+
const staticNamespaces = /* @__PURE__ */ new Set([...Object.keys(staticRouter), "admin"]);
|
|
1098
|
+
const availableDynamic = Object.fromEntries(Object.entries(dynamicRouter).filter(([namespace]) => {
|
|
1099
|
+
if (!staticNamespaces.has(namespace)) return true;
|
|
1100
|
+
warn(`Warning: generated namespace "${namespace}" is shadowed by a built-in command.`);
|
|
1101
|
+
return false;
|
|
1102
|
+
}));
|
|
1103
|
+
if (adminContract === void 0) return os.router({
|
|
1104
|
+
...availableDynamic,
|
|
1105
|
+
...staticRouter
|
|
1106
|
+
});
|
|
1107
|
+
return os.router({
|
|
1108
|
+
...availableDynamic,
|
|
1109
|
+
admin: createBridgeRouter(adminContract, bridgeOptions),
|
|
1110
|
+
...staticRouter
|
|
1111
|
+
});
|
|
1112
|
+
}
|
|
1113
|
+
async function runCli(dependencies = {}, argv) {
|
|
1114
|
+
const selected = extractOrganizationArg(argv ?? process.argv.slice(2));
|
|
1115
|
+
const command = selected.argv.at(0);
|
|
1116
|
+
const router = await buildRouter(dependencies, {
|
|
1117
|
+
orgCode: selected.orgCode,
|
|
1118
|
+
staticOnly: [
|
|
1119
|
+
"login",
|
|
1120
|
+
"logout",
|
|
1121
|
+
"openapi",
|
|
1122
|
+
"whoami",
|
|
1123
|
+
"sync"
|
|
1124
|
+
].includes(command ?? "")
|
|
1125
|
+
});
|
|
1126
|
+
const cli = createCli({
|
|
1127
|
+
router,
|
|
1128
|
+
name: "hitch"
|
|
1129
|
+
});
|
|
1130
|
+
const options = {
|
|
1131
|
+
argv: selected.argv,
|
|
1132
|
+
formatError: (error) => `hitch: ${error instanceof Error ? error.message : String(error)}`
|
|
1133
|
+
};
|
|
1134
|
+
const program = cli.buildProgram(options);
|
|
1135
|
+
const rootProgram = program;
|
|
1136
|
+
rootProgram.option("--org <code>", "Select a Hitch organization");
|
|
1137
|
+
const openapiCommand = program.commands?.find((command) => command.name() === "openapi");
|
|
1138
|
+
if (openapiCommand !== void 0) {
|
|
1139
|
+
openapiCommand._hidden = true;
|
|
1140
|
+
rootProgram.description(rootProgram.description().replace(/Available subcommands: [^\n]*/, `Available subcommands: ${program.commands?.filter((command) => command !== openapiCommand).map((command) => command.name()).join(", ")}`));
|
|
1141
|
+
}
|
|
1142
|
+
await cli.run(options, program);
|
|
1143
|
+
}
|
|
1144
|
+
//#endregion
|
|
1145
|
+
//#region bin/hitch.ts
|
|
1146
|
+
await runCli();
|
|
1147
|
+
//#endregion
|
|
1148
|
+
export {};
|
package/package.json
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@hitch42/cli",
|
|
3
|
+
"version": "0.1.0-beta.2",
|
|
4
|
+
"license": "MIT",
|
|
5
|
+
"bin": {
|
|
6
|
+
"hitch": "./dist/hitch.js"
|
|
7
|
+
},
|
|
8
|
+
"files": [
|
|
9
|
+
"dist/*.js"
|
|
10
|
+
],
|
|
11
|
+
"type": "module",
|
|
12
|
+
"dependencies": {
|
|
13
|
+
"@hey-api/openapi-ts": "0.0.0-next-20260824173136",
|
|
14
|
+
"@orpc/client": "^1.15.0",
|
|
15
|
+
"@orpc/contract": "^1.15.0",
|
|
16
|
+
"@orpc/openapi-client": "^1.15.0",
|
|
17
|
+
"@orpc/server": "^1.15.0",
|
|
18
|
+
"trpc-cli": "^0.15.1",
|
|
19
|
+
"zod": "^4.4.3"
|
|
20
|
+
},
|
|
21
|
+
"engines": {
|
|
22
|
+
"node": ">=22"
|
|
23
|
+
},
|
|
24
|
+
"repository": {
|
|
25
|
+
"type": "git",
|
|
26
|
+
"url": "git+https://github.com/hitch-42/hitch.git",
|
|
27
|
+
"directory": "packages/cli"
|
|
28
|
+
},
|
|
29
|
+
"publishConfig": {
|
|
30
|
+
"access": "public",
|
|
31
|
+
"registry": "https://registry.npmjs.org",
|
|
32
|
+
"tag": "beta"
|
|
33
|
+
}
|
|
34
|
+
}
|