@gjermundgaraba/clankercreds 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/dist/main.mjs +739 -0
- package/package.json +42 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Gjermund Garaba
|
|
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/main.mjs
ADDED
|
@@ -0,0 +1,739 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { dirname, join } from "node:path";
|
|
4
|
+
import { chmodSync, mkdirSync, readFileSync, renameSync, rmSync, unlinkSync, writeFileSync } from "node:fs";
|
|
5
|
+
import { randomBytes } from "node:crypto";
|
|
6
|
+
import { Option, Predicate, Schema } from "effect";
|
|
7
|
+
import { HttpClient, HttpClientRequest } from "effect/unstable/http";
|
|
8
|
+
import * as ActionHttpClient from "@gjermundgaraba/effect-actions/ActionHttpClient";
|
|
9
|
+
import * as Action from "@gjermundgaraba/effect-actions/Action";
|
|
10
|
+
import * as ActionGroup from "@gjermundgaraba/effect-actions/ActionGroup";
|
|
11
|
+
import * as ActionHttp from "@gjermundgaraba/effect-actions/ActionHttp";
|
|
12
|
+
import { YAMLMap, isMap, parseDocument } from "yaml";
|
|
13
|
+
//#region src/files.ts
|
|
14
|
+
/** Secrets are readable by their owner only, in directories only the owner can enter. */
|
|
15
|
+
const fileMode = 384;
|
|
16
|
+
const directoryMode = 448;
|
|
17
|
+
const isMissing = (error) => Predicate.hasProperty(error, "code") && error.code === "ENOENT";
|
|
18
|
+
/** A missing file is `undefined`. A file that exists but cannot be read is not ours to replace. */
|
|
19
|
+
const readText = (path) => {
|
|
20
|
+
try {
|
|
21
|
+
return readFileSync(path, "utf8");
|
|
22
|
+
} catch (error) {
|
|
23
|
+
if (isMissing(error)) return void 0;
|
|
24
|
+
throw error;
|
|
25
|
+
}
|
|
26
|
+
};
|
|
27
|
+
/**
|
|
28
|
+
* Write-then-rename, so a tool reading the file sees the old content or the new and never
|
|
29
|
+
* half of one. The directory is created private; an existing one keeps its mode.
|
|
30
|
+
*/
|
|
31
|
+
const writeAtomic = (path, content) => {
|
|
32
|
+
mkdirSync(dirname(path), {
|
|
33
|
+
recursive: true,
|
|
34
|
+
mode: directoryMode
|
|
35
|
+
});
|
|
36
|
+
const partial = `${path}.${randomBytes(6).toString("hex")}.tmp`;
|
|
37
|
+
try {
|
|
38
|
+
writeFileSync(partial, content, { mode: fileMode });
|
|
39
|
+
chmodSync(partial, fileMode);
|
|
40
|
+
renameSync(partial, path);
|
|
41
|
+
} catch (error) {
|
|
42
|
+
rmSync(partial, { force: true });
|
|
43
|
+
throw error;
|
|
44
|
+
}
|
|
45
|
+
};
|
|
46
|
+
/** True when there was a file to remove. */
|
|
47
|
+
const remove = (path) => {
|
|
48
|
+
try {
|
|
49
|
+
unlinkSync(path);
|
|
50
|
+
return true;
|
|
51
|
+
} catch (error) {
|
|
52
|
+
if (isMissing(error)) return false;
|
|
53
|
+
throw error;
|
|
54
|
+
}
|
|
55
|
+
};
|
|
56
|
+
const isJsonObject = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
57
|
+
/** The file as an object; a missing file is empty. Anything else is not ours to overwrite. */
|
|
58
|
+
const readJsonObject = (path) => {
|
|
59
|
+
const text = readText(path);
|
|
60
|
+
if (text === void 0 || text.trim() === "") return {};
|
|
61
|
+
const parsed = JSON.parse(text);
|
|
62
|
+
if (!isJsonObject(parsed)) throw new Error(`${path} does not hold a JSON object`);
|
|
63
|
+
return parsed;
|
|
64
|
+
};
|
|
65
|
+
/**
|
|
66
|
+
* A nested object a placement merges into; a missing one is empty. Anything else under the
|
|
67
|
+
* key is someone else's, however malformed, and not ours to overwrite.
|
|
68
|
+
*/
|
|
69
|
+
const objectAt = (parent, key, path) => {
|
|
70
|
+
const value = parent[key];
|
|
71
|
+
if (value === void 0) return {};
|
|
72
|
+
if (!isJsonObject(value)) throw new Error(`${key} in ${path} is not a JSON object; refusing to replace it`);
|
|
73
|
+
return value;
|
|
74
|
+
};
|
|
75
|
+
const writeJson = (path, value) => writeAtomic(path, `${JSON.stringify(value, null, 2)}\n`);
|
|
76
|
+
//#endregion
|
|
77
|
+
//#region src/config.ts
|
|
78
|
+
/** Where the recipe puts `config.json` and `key`. The variable exists for tests and odd images. */
|
|
79
|
+
const configDirectory = () => process.env["CLANKERCREDS_CONFIG_DIR"] ?? "/etc/clankercreds";
|
|
80
|
+
/** The environment is read here and nowhere else, so a placement goes only where it is told. */
|
|
81
|
+
const configHome = (home) => {
|
|
82
|
+
const xdg = process.env["XDG_CONFIG_HOME"];
|
|
83
|
+
return xdg === void 0 || xdg === "" ? join(home, ".config") : xdg;
|
|
84
|
+
};
|
|
85
|
+
/** The bundle is not named here: the key is bound to it on the service. */
|
|
86
|
+
const loadConfig = () => {
|
|
87
|
+
const directory = configDirectory();
|
|
88
|
+
const file = join(directory, "config.json");
|
|
89
|
+
const { service } = readJsonObject(file);
|
|
90
|
+
if (typeof service !== "string") throw new Error(`${file} must hold {"service": "https://…"}`);
|
|
91
|
+
const key = readText(join(directory, "key"))?.trim();
|
|
92
|
+
if (key === void 0 || key === "") throw new Error(`${join(directory, "key")} must hold the machine key`);
|
|
93
|
+
const home = homedir();
|
|
94
|
+
return {
|
|
95
|
+
service: service.replace(/\/+$/, ""),
|
|
96
|
+
key,
|
|
97
|
+
home,
|
|
98
|
+
configHome: configHome(home)
|
|
99
|
+
};
|
|
100
|
+
};
|
|
101
|
+
//#endregion
|
|
102
|
+
//#region ../../node_modules/.pnpm/@gjermundgaraba+clankerauth-sdk@0.9.0_@gjermundgaraba+effect-actions@0.7.0_effect@4.0.0-rc.117_/node_modules/@gjermundgaraba/clankerauth-sdk/dist/errors.js
|
|
103
|
+
/**
|
|
104
|
+
* Every refusal this package produces, as schemas. This entry point is browser-safe:
|
|
105
|
+
* a shared contract declares these on its surface and a browser client decodes them,
|
|
106
|
+
* without pulling token verification or its cryptography into the bundle.
|
|
107
|
+
*
|
|
108
|
+
* Schemas define public responses. Diagnostic causes are internal, non-enumerable fields.
|
|
109
|
+
*/
|
|
110
|
+
var Unauthorized = class extends Schema.TaggedError()("Unauthorized", { message: Schema.String }, { httpApiStatus: 401 }) {
|
|
111
|
+
constructor(options) {
|
|
112
|
+
super({ message: options.message });
|
|
113
|
+
Object.defineProperty(this, "cause", { value: options.cause });
|
|
114
|
+
}
|
|
115
|
+
};
|
|
116
|
+
var RateLimited = class extends Schema.TaggedError()("RateLimited", { message: Schema.String }, { httpApiStatus: 429 }) {};
|
|
117
|
+
var ProviderUnavailable = class extends Schema.TaggedError()("ProviderUnavailable", { operation: Schema.String }, { httpApiStatus: 503 }) {
|
|
118
|
+
constructor(options) {
|
|
119
|
+
super({ operation: options.operation });
|
|
120
|
+
Object.defineProperty(this, "cause", { value: options.cause });
|
|
121
|
+
}
|
|
122
|
+
};
|
|
123
|
+
/**
|
|
124
|
+
* A verified credential lacks one scope the requested access needs. It names only
|
|
125
|
+
* the missing scope, so a refusal never enumerates the resource's permissions.
|
|
126
|
+
*/
|
|
127
|
+
var InsufficientScope = class extends Schema.TaggedError()("InsufficientScope", { scope: Schema.String }, { httpApiStatus: 403 }) {};
|
|
128
|
+
Schema.TaggedError()("ConfigurationError", { message: Schema.String });
|
|
129
|
+
/**
|
|
130
|
+
* What verification, admission and the authorization hook refuse with — one list, so a
|
|
131
|
+
* surface declares `errors: authenticationErrors` and answers every refusal the same way.
|
|
132
|
+
*/
|
|
133
|
+
const authenticationErrors = [
|
|
134
|
+
Unauthorized,
|
|
135
|
+
InsufficientScope,
|
|
136
|
+
RateLimited,
|
|
137
|
+
ProviderUnavailable
|
|
138
|
+
];
|
|
139
|
+
//#endregion
|
|
140
|
+
//#region ../../packages/api/src/bundle.ts
|
|
141
|
+
const GhCredential = Schema.Struct({
|
|
142
|
+
type: Schema.Literal("gh"),
|
|
143
|
+
host: Schema.String,
|
|
144
|
+
token: Schema.String
|
|
145
|
+
});
|
|
146
|
+
const ClaudeCredential = Schema.Struct({
|
|
147
|
+
type: Schema.Literal("claude"),
|
|
148
|
+
token: Schema.String
|
|
149
|
+
});
|
|
150
|
+
/**
|
|
151
|
+
* What Codex keeps in its `auth.json`, always written together: the three belong to one
|
|
152
|
+
* login. No refresh token: the client writes `<clientId>:<key>` in its place.
|
|
153
|
+
*/
|
|
154
|
+
const CodexLogin = Schema.Struct({
|
|
155
|
+
accessToken: Schema.String,
|
|
156
|
+
idToken: Schema.String,
|
|
157
|
+
accountId: Schema.String
|
|
158
|
+
});
|
|
159
|
+
const CodexCredential = Schema.Struct({
|
|
160
|
+
type: Schema.Literal("codex"),
|
|
161
|
+
...CodexLogin.fields
|
|
162
|
+
});
|
|
163
|
+
/**
|
|
164
|
+
* The access token `clankercreds token` answers pi with until it is near expiry: pi asks the
|
|
165
|
+
* command on every model call, and the command asks the service only after that.
|
|
166
|
+
*/
|
|
167
|
+
const PiCredential = Schema.Struct({
|
|
168
|
+
type: Schema.Literal("pi"),
|
|
169
|
+
accessToken: Schema.String
|
|
170
|
+
});
|
|
171
|
+
/**
|
|
172
|
+
* The client is baked into images, so old clients read what the service sends. A change
|
|
173
|
+
* an old client would get wrong is a new type name, which it skips: the decoder drops fields
|
|
174
|
+
* it does not know, so one ignoring `host` would place a GHE token as github.com's. A field
|
|
175
|
+
* an old client can safely ignore may be added, as pi's `accessToken` was; the service ships
|
|
176
|
+
* first, since a new client refuses a bundle that lacks it.
|
|
177
|
+
*/
|
|
178
|
+
const TypedCredential = Schema.Union([
|
|
179
|
+
GhCredential,
|
|
180
|
+
ClaudeCredential,
|
|
181
|
+
CodexCredential,
|
|
182
|
+
PiCredential
|
|
183
|
+
]);
|
|
184
|
+
/** The types this build knows, derived from the union so there is one list. */
|
|
185
|
+
const knownTypes = TypedCredential.members.map((member) => member.fields.type.literal);
|
|
186
|
+
const isKnownType = (type) => knownTypes.some((known) => known === type);
|
|
187
|
+
/**
|
|
188
|
+
* What the service may send that this client does not know yet. It decodes instead of
|
|
189
|
+
* failing, so the client can skip it with a warning. A known type that fails its own
|
|
190
|
+
* schema must not land here, or a broken credential would be placed as an empty one.
|
|
191
|
+
*/
|
|
192
|
+
const UnknownCredential = Schema.Struct({ type: Schema.String.check(Schema.makeFilter((type) => !isKnownType(type), { message: "a known credential type must match its schema" })) });
|
|
193
|
+
/** Everything the machine's bundle holds, sent in full on every sync. */
|
|
194
|
+
const Bundle = Schema.Struct({ credentials: Schema.Array(Schema.Union([TypedCredential, UnknownCredential])) });
|
|
195
|
+
//#endregion
|
|
196
|
+
//#region ../../packages/api/src/shared.ts
|
|
197
|
+
/** The caller asked for something the service cannot do with the input given. */
|
|
198
|
+
var BadRequest = class extends Schema.TaggedError()("BadRequest", { message: Schema.String }, { httpApiStatus: 400 }) {};
|
|
199
|
+
/**
|
|
200
|
+
* What was asked for does not exist: a bundle, credential or sign-in by that name, a
|
|
201
|
+
* bundle for the calling key, or a ChatGPT login in the bundle that has none.
|
|
202
|
+
*/
|
|
203
|
+
var NotFound = class extends Schema.TaggedError()("NotFound", { message: Schema.String }, { httpApiStatus: 404 }) {};
|
|
204
|
+
Schema.TaggedError()("Conflict", { message: Schema.String }, { httpApiStatus: 409 });
|
|
205
|
+
/** The ChatGPT login is dead, or was never completed. Only a new device sign-in revives it. */
|
|
206
|
+
var NeedsSignIn = class extends Schema.TaggedError()("NeedsSignIn", { message: Schema.String }, { httpApiStatus: 409 }) {};
|
|
207
|
+
/** OpenAI could not be reached and no valid token is held. Waiting helps; retrying now does not. */
|
|
208
|
+
var UpstreamUnavailable = class extends Schema.TaggedError()("UpstreamUnavailable", { message: Schema.String }, { httpApiStatus: 503 }) {};
|
|
209
|
+
/** The request could not be represented on the wire; never a caller-fixable failure. */
|
|
210
|
+
var InternalError = class extends Schema.TaggedError()("InternalError", { message: Schema.String }, { httpApiStatus: 500 }) {};
|
|
211
|
+
/** Transport failures answer with the same public errors handlers use. */
|
|
212
|
+
const schemaError = {
|
|
213
|
+
invalid: {
|
|
214
|
+
schema: BadRequest,
|
|
215
|
+
make: () => new BadRequest({ message: "The request does not match the action's input." })
|
|
216
|
+
},
|
|
217
|
+
internal: {
|
|
218
|
+
schema: InternalError,
|
|
219
|
+
make: () => new InternalError({ message: "The response could not be encoded." })
|
|
220
|
+
}
|
|
221
|
+
};
|
|
222
|
+
Schema.String.check(Schema.isPattern(/^[a-z0-9](?:[a-z0-9-]{0,62}[a-z0-9])?$/));
|
|
223
|
+
Schema.String.check(Schema.isPattern(/^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9-]*[a-z0-9])?)*$/), Schema.isMaxLength(253));
|
|
224
|
+
/**
|
|
225
|
+
* The machine's self-generated label. It identifies nothing; it only groups audit rows.
|
|
226
|
+
* It excludes `:` because the Codex refresh token is `<clientId>:<key>`.
|
|
227
|
+
*/
|
|
228
|
+
const ClientId = Schema.String.check(Schema.isPattern(/^[A-Za-z0-9_-]{1,64}$/));
|
|
229
|
+
const Claims = Schema.Struct({
|
|
230
|
+
exp: Schema.optionalKey(Schema.Number),
|
|
231
|
+
email: Schema.optionalKey(Schema.String),
|
|
232
|
+
"https://api.openai.com/auth": Schema.optionalKey(Schema.Struct({ chatgpt_account_id: Schema.optionalKey(Schema.String) }))
|
|
233
|
+
});
|
|
234
|
+
/**
|
|
235
|
+
* The claims of an OpenAI JWT, unverified: they are read for their timing and their
|
|
236
|
+
* account, never trusted for anything. Anything unreadable is simply no claims.
|
|
237
|
+
*/
|
|
238
|
+
const claimsOf = (jwt) => {
|
|
239
|
+
const payload = Buffer.from(jwt.split(".")[1] ?? "", "base64url").toString("utf8");
|
|
240
|
+
const claims = Schema.decodeUnknownOption(Schema.fromJsonString(Claims))(payload);
|
|
241
|
+
return Option.match(claims, {
|
|
242
|
+
onNone: () => ({
|
|
243
|
+
exp: void 0,
|
|
244
|
+
email: void 0,
|
|
245
|
+
accountId: void 0
|
|
246
|
+
}),
|
|
247
|
+
onSome: (value) => ({
|
|
248
|
+
exp: value.exp,
|
|
249
|
+
email: value.email,
|
|
250
|
+
accountId: value["https://api.openai.com/auth"]?.chatgpt_account_id
|
|
251
|
+
})
|
|
252
|
+
});
|
|
253
|
+
};
|
|
254
|
+
//#endregion
|
|
255
|
+
//#region ../../packages/api/src/machine.ts
|
|
256
|
+
/**
|
|
257
|
+
* What a machine key can do: read the one bundle bound to it, and nothing else. The
|
|
258
|
+
* bundle is never named: the key is bound to it on the server.
|
|
259
|
+
*/
|
|
260
|
+
const Machine = ActionGroup.make({
|
|
261
|
+
name: "machine",
|
|
262
|
+
schemaError
|
|
263
|
+
}, Action.make("bundle", {
|
|
264
|
+
access: "read",
|
|
265
|
+
description: "Fetch the typed credentials of the bundle bound to the calling key.",
|
|
266
|
+
input: Schema.Struct({ clientId: ClientId }),
|
|
267
|
+
success: Bundle,
|
|
268
|
+
errors: [NotFound],
|
|
269
|
+
mcp: false
|
|
270
|
+
}), Action.make("codexLogin", {
|
|
271
|
+
access: "read",
|
|
272
|
+
description: "The current ChatGPT login of the bundle bound to the calling key, as the codex type carries it, refreshed first when it has expired.",
|
|
273
|
+
input: Schema.Struct({ clientId: ClientId }),
|
|
274
|
+
success: CodexLogin,
|
|
275
|
+
errors: [
|
|
276
|
+
NotFound,
|
|
277
|
+
NeedsSignIn,
|
|
278
|
+
UpstreamUnavailable
|
|
279
|
+
],
|
|
280
|
+
mcp: false
|
|
281
|
+
}));
|
|
282
|
+
/**
|
|
283
|
+
* Codex treats a 401 as permanent and stops retrying; every other failure is transient.
|
|
284
|
+
* The codes are the ones Codex classifies: `refresh_token_invalidated` reads as revoked.
|
|
285
|
+
*/
|
|
286
|
+
var CodexRefreshRejected = class extends Schema.TaggedError()("CodexRefreshRejected", { error: Schema.Struct({
|
|
287
|
+
code: Schema.String,
|
|
288
|
+
message: Schema.String
|
|
289
|
+
}) }, { httpApiStatus: 401 }) {};
|
|
290
|
+
var CodexRefreshUnavailable = class extends Schema.TaggedError()("CodexRefreshUnavailable", { error: Schema.Struct({
|
|
291
|
+
code: Schema.String,
|
|
292
|
+
message: Schema.String
|
|
293
|
+
}) }, { httpApiStatus: 503 }) {};
|
|
294
|
+
/**
|
|
295
|
+
* Codex's own refresh request, the one request whose shape is not ours. The refresh-token
|
|
296
|
+
* field carries `<clientId>:<key>`, so this group authenticates in its handler instead of
|
|
297
|
+
* through bearer middleware. The answer leaves `refresh_token` out, so Codex keeps the
|
|
298
|
+
* composite it has.
|
|
299
|
+
*/
|
|
300
|
+
const Codex = ActionGroup.make({ name: "codex" }, Action.make("refresh", {
|
|
301
|
+
access: "read",
|
|
302
|
+
description: "Answer Codex's token refresh with the bundle's current ChatGPT tokens.",
|
|
303
|
+
input: Schema.Struct({
|
|
304
|
+
grant_type: Schema.Literal("refresh_token"),
|
|
305
|
+
refresh_token: Schema.String,
|
|
306
|
+
client_id: Schema.optionalKey(Schema.String),
|
|
307
|
+
scope: Schema.optionalKey(Schema.String)
|
|
308
|
+
}),
|
|
309
|
+
success: Schema.Struct({
|
|
310
|
+
access_token: Schema.String,
|
|
311
|
+
id_token: Schema.String
|
|
312
|
+
}),
|
|
313
|
+
errors: [CodexRefreshRejected, CodexRefreshUnavailable],
|
|
314
|
+
mcp: false
|
|
315
|
+
}));
|
|
316
|
+
/** `<clientId>:<key>`. Client ids exclude `:`, so the key is the rest. */
|
|
317
|
+
const formatCodexRefreshToken = (clientId, key) => `${clientId}:${key}`;
|
|
318
|
+
const parseCodexRefreshToken = (value) => {
|
|
319
|
+
const colon = value.indexOf(":");
|
|
320
|
+
return colon < 1 || colon === value.length - 1 ? void 0 : {
|
|
321
|
+
clientId: value.slice(0, colon),
|
|
322
|
+
key: value.slice(colon + 1)
|
|
323
|
+
};
|
|
324
|
+
};
|
|
325
|
+
const MachineHttp = ActionHttp.make({
|
|
326
|
+
apiPath: "/v1",
|
|
327
|
+
errors: authenticationErrors
|
|
328
|
+
}, Machine);
|
|
329
|
+
ActionHttp.make({ apiPath: "/v1" }, Codex);
|
|
330
|
+
Object.keys(ActionGroup.contracts(Machine, Codex)).map((key) => `/v1/${key.replace(".", "/")}`);
|
|
331
|
+
//#endregion
|
|
332
|
+
//#region src/service.ts
|
|
333
|
+
/** The service answered and said no. Retrying the same request will not help. */
|
|
334
|
+
var Refused = class extends Error {};
|
|
335
|
+
/** No answer worth acting on. Whatever the machine already holds stays in place. */
|
|
336
|
+
var Unreachable = class extends Error {};
|
|
337
|
+
/**
|
|
338
|
+
* The deadline covers the whole request, the body included. It matters most to `token`:
|
|
339
|
+
* pi gives the command about ten seconds, and a hang would cost it a token it already holds.
|
|
340
|
+
*/
|
|
341
|
+
const connect = (config, deadlineMs) => ActionHttpClient.promise(MachineHttp, {
|
|
342
|
+
baseUrl: config.service,
|
|
343
|
+
transformClient: HttpClient.mapRequest(HttpClientRequest.bearerToken(config.key)),
|
|
344
|
+
fetch: (input, init) => {
|
|
345
|
+
const deadline = AbortSignal.timeout(deadlineMs);
|
|
346
|
+
const signal = init?.signal ? AbortSignal.any([init.signal, deadline]) : deadline;
|
|
347
|
+
return fetch(input, {
|
|
348
|
+
...init,
|
|
349
|
+
signal
|
|
350
|
+
});
|
|
351
|
+
}
|
|
352
|
+
});
|
|
353
|
+
/**
|
|
354
|
+
* The key's own refusals and the declared answers are refusals. Whatever is left is an
|
|
355
|
+
* outage: transport, upstream, a broken answer, or a service that never answers.
|
|
356
|
+
*/
|
|
357
|
+
const classify = (error) => {
|
|
358
|
+
if (error instanceof Unauthorized) return new Refused("The machine key was refused. It may have been revoked.");
|
|
359
|
+
if (error instanceof InsufficientScope) return new Refused("The machine key does not carry the machine scope.");
|
|
360
|
+
if (error instanceof NotFound || error instanceof BadRequest || error instanceof NeedsSignIn) return new Refused(error.message);
|
|
361
|
+
return new Unreachable(error instanceof Error ? error.message : String(error));
|
|
362
|
+
};
|
|
363
|
+
const refuse = (error) => Promise.reject(classify(error));
|
|
364
|
+
const fetchBundle = (config, clientId) => connect(config, 3e4).machine.bundle({ clientId }).catch(refuse);
|
|
365
|
+
const fetchCodexLogin = (config, clientId) => connect(config, 5e3).machine.codexLogin({ clientId }).catch(refuse);
|
|
366
|
+
//#endregion
|
|
367
|
+
//#region src/place/claude.ts
|
|
368
|
+
const settingsFile = (home) => join(home, ".claude/settings.json");
|
|
369
|
+
const variable = "CLAUDE_CODE_OAUTH_TOKEN";
|
|
370
|
+
/**
|
|
371
|
+
* The mark that the token next to it is ours: a token alone cannot say who set it. It
|
|
372
|
+
* sits in the same `env` block, where Claude Code passes it through and nothing reads it.
|
|
373
|
+
*/
|
|
374
|
+
const mark$1 = "CLANKERCREDS_CLAUDE";
|
|
375
|
+
/** Only the variable and its mark are ours; every other setting in the file is kept. */
|
|
376
|
+
const placeClaude = (home, token) => {
|
|
377
|
+
const settings = readJsonObject(settingsFile(home));
|
|
378
|
+
const env = objectAt(settings, "env", settingsFile(home));
|
|
379
|
+
if (variable in env && !(mark$1 in env)) throw new Error(`${variable} in ${settingsFile(home)} was not set by clankercreds; refusing to replace it`);
|
|
380
|
+
writeJson(settingsFile(home), {
|
|
381
|
+
...settings,
|
|
382
|
+
env: {
|
|
383
|
+
...env,
|
|
384
|
+
[variable]: token,
|
|
385
|
+
[mark$1]: "placed"
|
|
386
|
+
}
|
|
387
|
+
});
|
|
388
|
+
};
|
|
389
|
+
/** True when our marked variable was there. A token someone else set is left alone. */
|
|
390
|
+
const removeClaude = (home) => {
|
|
391
|
+
const settings = readJsonObject(settingsFile(home));
|
|
392
|
+
const env = settings["env"];
|
|
393
|
+
if (!isJsonObject(env) || !(mark$1 in env)) return false;
|
|
394
|
+
const { [variable]: _removed, [mark$1]: _mark, ...rest } = env;
|
|
395
|
+
const { env: _env, ...others } = settings;
|
|
396
|
+
writeJson(settingsFile(home), Object.keys(rest).length === 0 ? others : {
|
|
397
|
+
...others,
|
|
398
|
+
env: rest
|
|
399
|
+
});
|
|
400
|
+
return true;
|
|
401
|
+
};
|
|
402
|
+
//#endregion
|
|
403
|
+
//#region src/place/codex.ts
|
|
404
|
+
const authFile$1 = (home) => join(home, ".codex/auth.json");
|
|
405
|
+
/**
|
|
406
|
+
* The file's tokens, when its refresh-token field is a composite: then clankercreds wrote
|
|
407
|
+
* it, for whichever identity the composite names. A login anyone else wrote has a real
|
|
408
|
+
* refresh token there.
|
|
409
|
+
*/
|
|
410
|
+
const ownTokens = (auth) => {
|
|
411
|
+
const tokens = auth["tokens"];
|
|
412
|
+
if (!isJsonObject(tokens)) return void 0;
|
|
413
|
+
const { access_token, refresh_token } = tokens;
|
|
414
|
+
if (typeof access_token !== "string" || typeof refresh_token !== "string") return void 0;
|
|
415
|
+
if (parseCodexRefreshToken(refresh_token) === void 0) return void 0;
|
|
416
|
+
return {
|
|
417
|
+
accessToken: access_token,
|
|
418
|
+
refreshToken: refresh_token
|
|
419
|
+
};
|
|
420
|
+
};
|
|
421
|
+
/**
|
|
422
|
+
* The one writer of the file, and it always writes the whole login: the three tokens
|
|
423
|
+
* belong together. The refresh-token field never holds OpenAI's token: it names this
|
|
424
|
+
* machine to the service, which Codex calls when `CODEX_REFRESH_TOKEN_URL_OVERRIDE` is
|
|
425
|
+
* set. A login someone else made is never replaced.
|
|
426
|
+
*/
|
|
427
|
+
const placeCodex = (home, login, identity) => {
|
|
428
|
+
const existing = readJsonObject(authFile$1(home));
|
|
429
|
+
if (Object.keys(existing).length > 0 && ownTokens(existing) === void 0) throw new Error(`${authFile$1(home)} was not written by clankercreds; refusing to replace it`);
|
|
430
|
+
writeJson(authFile$1(home), {
|
|
431
|
+
auth_mode: "chatgpt",
|
|
432
|
+
OPENAI_API_KEY: null,
|
|
433
|
+
tokens: {
|
|
434
|
+
id_token: login.idToken,
|
|
435
|
+
access_token: login.accessToken,
|
|
436
|
+
refresh_token: formatCodexRefreshToken(identity.clientId, identity.key),
|
|
437
|
+
account_id: login.accountId
|
|
438
|
+
},
|
|
439
|
+
last_refresh: (/* @__PURE__ */ new Date()).toISOString()
|
|
440
|
+
});
|
|
441
|
+
};
|
|
442
|
+
/** True when a file of ours was there. A login someone else wrote is left alone. */
|
|
443
|
+
const removeCodex = (home) => ownTokens(readJsonObject(authFile$1(home))) === void 0 ? false : remove(authFile$1(home));
|
|
444
|
+
//#endregion
|
|
445
|
+
//#region src/place/gh.ts
|
|
446
|
+
const hostsFile = (configHome) => join(configHome, "gh/hosts.yml");
|
|
447
|
+
/** Git's XDG config, so `~/.gitconfig` is never touched. */
|
|
448
|
+
const gitConfig = (configHome) => join(configHome, "git/config");
|
|
449
|
+
/**
|
|
450
|
+
* `hosts.yml` maps each host to its settings. An entry holding this key is ours to replace
|
|
451
|
+
* and to remove; `gh` keeps keys it does not know when it rewrites the file, and every other
|
|
452
|
+
* host in it is left as it was, though not always byte for byte.
|
|
453
|
+
*/
|
|
454
|
+
const mark = "clankercreds";
|
|
455
|
+
const isOurs = ({ value }) => isMap(value) && value.get(mark) === true;
|
|
456
|
+
/** The file's entries. What is not valid YAML, or not a map of hosts, is not ours to replace. */
|
|
457
|
+
const readHosts = (path) => {
|
|
458
|
+
const doc = parseDocument(readText(path) ?? "");
|
|
459
|
+
if (doc.errors.length > 0 || doc.contents !== null && !isMap(doc.contents)) throw new Error(`${path} is not a map of hosts; refusing to replace it`);
|
|
460
|
+
const hosts = isMap(doc.contents) ? doc.contents : new YAMLMap(doc.schema);
|
|
461
|
+
doc.contents = hosts;
|
|
462
|
+
return {
|
|
463
|
+
doc,
|
|
464
|
+
hosts
|
|
465
|
+
};
|
|
466
|
+
};
|
|
467
|
+
/** gh's own layout: four-space indents, and no long line folded. */
|
|
468
|
+
const hostsText = (doc) => doc.toString({
|
|
469
|
+
indent: 4,
|
|
470
|
+
lineWidth: 0
|
|
471
|
+
});
|
|
472
|
+
const begin = "# >>> clankercreds";
|
|
473
|
+
const end = "# <<< clankercreds";
|
|
474
|
+
/** Everything outside the marked block belongs to someone else and is kept byte for byte. */
|
|
475
|
+
const withoutBlock = (text) => {
|
|
476
|
+
const start = text.indexOf(begin);
|
|
477
|
+
const stop = text.indexOf(end);
|
|
478
|
+
if (start === -1 || stop === -1) return text;
|
|
479
|
+
return `${text.slice(0, start)}${text.slice(stop + 18).replace(/^\n/, "")}`;
|
|
480
|
+
};
|
|
481
|
+
/** The empty `helper =` resets any inherited helper, so only `gh` answers for these hosts. */
|
|
482
|
+
const credentialHelpers = (hosts) => [
|
|
483
|
+
begin,
|
|
484
|
+
...hosts.flatMap((host) => [
|
|
485
|
+
`[credential "https://${host}"]`,
|
|
486
|
+
" helper =",
|
|
487
|
+
" helper = !gh auth git-credential"
|
|
488
|
+
]),
|
|
489
|
+
end,
|
|
490
|
+
""
|
|
491
|
+
].join("\n");
|
|
492
|
+
const appended = (text, addition) => `${text}${text === "" || text.endsWith("\n") ? "" : "\n"}${addition}`;
|
|
493
|
+
/** Our entries go at the end of the file, one per host, whatever was there before. */
|
|
494
|
+
const placeGh = (configHome, credentials) => {
|
|
495
|
+
const { doc, hosts } = readHosts(hostsFile(configHome));
|
|
496
|
+
hosts.items = hosts.items.filter((pair) => !isOurs(pair));
|
|
497
|
+
const foreign = credentials.find(({ host }) => hosts.has(host));
|
|
498
|
+
if (foreign !== void 0) throw new Error(`${foreign.host} in ${hostsFile(configHome)} was not set by clankercreds; refusing to replace it`);
|
|
499
|
+
for (const { host, token } of credentials) hosts.set(host, doc.createNode({
|
|
500
|
+
oauth_token: token,
|
|
501
|
+
git_protocol: "https",
|
|
502
|
+
[mark]: true
|
|
503
|
+
}));
|
|
504
|
+
writeAtomic(hostsFile(configHome), hostsText(doc));
|
|
505
|
+
const config = withoutBlock(readText(gitConfig(configHome)) ?? "");
|
|
506
|
+
writeAtomic(gitConfig(configHome), appended(config, credentialHelpers(credentials.map(({ host }) => host))));
|
|
507
|
+
};
|
|
508
|
+
/** True when something of ours was there. Safe to run when nothing was placed. */
|
|
509
|
+
const removeGh = (configHome) => {
|
|
510
|
+
const file = hostsFile(configHome);
|
|
511
|
+
const { doc, hosts } = readHosts(file);
|
|
512
|
+
const kept = hosts.items.filter((pair) => !isOurs(pair));
|
|
513
|
+
const owned = kept.length < hosts.items.length;
|
|
514
|
+
if (owned) {
|
|
515
|
+
hosts.items = kept;
|
|
516
|
+
if (kept.length === 0) remove(file);
|
|
517
|
+
else writeAtomic(file, hostsText(doc));
|
|
518
|
+
}
|
|
519
|
+
const existing = readText(gitConfig(configHome));
|
|
520
|
+
if (existing === void 0) return owned;
|
|
521
|
+
const config = withoutBlock(existing);
|
|
522
|
+
if (config === existing) return owned;
|
|
523
|
+
if (config.trim() === "") remove(gitConfig(configHome));
|
|
524
|
+
else writeAtomic(gitConfig(configHome), config);
|
|
525
|
+
return true;
|
|
526
|
+
};
|
|
527
|
+
//#endregion
|
|
528
|
+
//#region src/state.ts
|
|
529
|
+
const stateDirectory = (home) => join(home, ".local/state/clankercreds");
|
|
530
|
+
/**
|
|
531
|
+
* A label for the audit log, generated on the machine on first use. It must never be
|
|
532
|
+
* created during an image build, or every machine from that image would share it.
|
|
533
|
+
*/
|
|
534
|
+
const clientId = (home) => {
|
|
535
|
+
const file = join(stateDirectory(home), "client-id");
|
|
536
|
+
const existing = readText(file)?.trim();
|
|
537
|
+
if (existing !== void 0 && existing !== "") return existing;
|
|
538
|
+
const generated = `m_${randomBytes(9).toString("base64url")}`;
|
|
539
|
+
writeAtomic(file, `${generated}\n`);
|
|
540
|
+
return generated;
|
|
541
|
+
};
|
|
542
|
+
/** The access token `token` last fetched for pi. The client's own copy; no tool reads it. */
|
|
543
|
+
const accessTokenFile = (home) => join(stateDirectory(home), "access-token");
|
|
544
|
+
//#endregion
|
|
545
|
+
//#region src/place/pi.ts
|
|
546
|
+
const modelsFile = (home) => join(home, ".pi/agent/models.json");
|
|
547
|
+
const authFile = (home) => join(home, ".pi/agent/auth.json");
|
|
548
|
+
const provider = "openai-codex";
|
|
549
|
+
/** pi runs this on every model call; the client answers from its own cached token. It is the mark. */
|
|
550
|
+
const command = "!clankercreds token";
|
|
551
|
+
/**
|
|
552
|
+
* pi checks its own credential store before a configured key, so a stored ChatGPT login
|
|
553
|
+
* would win over the command and refresh against OpenAI behind the service's back. It is
|
|
554
|
+
* someone's login, so it is not removed: the sync stops until they sign pi out.
|
|
555
|
+
*/
|
|
556
|
+
const refuseStoredLogin = (home) => {
|
|
557
|
+
if (provider in readJsonObject(authFile(home))) throw new Error(`pi is signed in to ChatGPT in ${authFile(home)}; sign it out, or the login would bypass the service`);
|
|
558
|
+
};
|
|
559
|
+
/**
|
|
560
|
+
* Only the key is ours; every other provider setting in the file is kept. The token goes
|
|
561
|
+
* first, so whatever stops the key, `token` never answers for a login the bundle dropped.
|
|
562
|
+
*/
|
|
563
|
+
const placePi = (home, accessToken) => {
|
|
564
|
+
writeAtomic(accessTokenFile(home), `${accessToken}\n`);
|
|
565
|
+
refuseStoredLogin(home);
|
|
566
|
+
const models = readJsonObject(modelsFile(home));
|
|
567
|
+
const providers = objectAt(models, "providers", modelsFile(home));
|
|
568
|
+
const existing = objectAt(providers, provider, modelsFile(home));
|
|
569
|
+
if ("apiKey" in existing && existing["apiKey"] !== command) throw new Error(`the ${provider} key in ${modelsFile(home)} was not set by clankercreds; refusing to replace it`);
|
|
570
|
+
writeJson(modelsFile(home), {
|
|
571
|
+
...models,
|
|
572
|
+
providers: {
|
|
573
|
+
...providers,
|
|
574
|
+
[provider]: {
|
|
575
|
+
...existing,
|
|
576
|
+
apiKey: command
|
|
577
|
+
}
|
|
578
|
+
}
|
|
579
|
+
});
|
|
580
|
+
};
|
|
581
|
+
const removeEntry = (home) => {
|
|
582
|
+
const models = readJsonObject(modelsFile(home));
|
|
583
|
+
const providers = models["providers"];
|
|
584
|
+
if (!isJsonObject(providers)) return false;
|
|
585
|
+
const existing = providers[provider];
|
|
586
|
+
if (!isJsonObject(existing) || existing["apiKey"] !== command) return false;
|
|
587
|
+
const { apiKey: _removed, ...rest } = existing;
|
|
588
|
+
const { [provider]: _provider, ...others } = providers;
|
|
589
|
+
writeJson(modelsFile(home), {
|
|
590
|
+
...models,
|
|
591
|
+
providers: Object.keys(rest).length === 0 ? others : {
|
|
592
|
+
...others,
|
|
593
|
+
[provider]: rest
|
|
594
|
+
}
|
|
595
|
+
});
|
|
596
|
+
return true;
|
|
597
|
+
};
|
|
598
|
+
/**
|
|
599
|
+
* True when our entry was there. `token`'s copy goes with it, whoever owns the key, which
|
|
600
|
+
* is left alone when it is someone else's.
|
|
601
|
+
*/
|
|
602
|
+
const removePi = (home) => {
|
|
603
|
+
remove(accessTokenFile(home));
|
|
604
|
+
return removeEntry(home);
|
|
605
|
+
};
|
|
606
|
+
//#endregion
|
|
607
|
+
//#region src/sync.ts
|
|
608
|
+
/**
|
|
609
|
+
* Where each type goes and how it is undone. Removal touches only what this client wrote
|
|
610
|
+
* and says whether anything was there. A type missing here does not compile.
|
|
611
|
+
*/
|
|
612
|
+
const placements = {
|
|
613
|
+
gh: {
|
|
614
|
+
place: (config, _identity, credentials) => placeGh(config.configHome, credentials),
|
|
615
|
+
remove: (config) => removeGh(config.configHome)
|
|
616
|
+
},
|
|
617
|
+
claude: {
|
|
618
|
+
place: (config, _identity, [credential]) => placeClaude(config.home, credential.token),
|
|
619
|
+
remove: (config) => removeClaude(config.home)
|
|
620
|
+
},
|
|
621
|
+
codex: {
|
|
622
|
+
place: (config, identity, [credential]) => placeCodex(config.home, credential, identity),
|
|
623
|
+
remove: (config) => removeCodex(config.home)
|
|
624
|
+
},
|
|
625
|
+
pi: {
|
|
626
|
+
place: (config, _identity, [credential]) => placePi(config.home, credential.accessToken),
|
|
627
|
+
remove: (config) => removePi(config.home)
|
|
628
|
+
}
|
|
629
|
+
};
|
|
630
|
+
/** What the bundle carries of one type is placed; a type it does not carry is undone. */
|
|
631
|
+
const settle = (config, identity, known, type) => {
|
|
632
|
+
const credentials = known.filter((credential) => credential.type === type);
|
|
633
|
+
if (credentials.length === 0) return placements[type].remove(config) ? "removed" : void 0;
|
|
634
|
+
placements[type].place(config, identity, credentials);
|
|
635
|
+
return "placed";
|
|
636
|
+
};
|
|
637
|
+
/**
|
|
638
|
+
* Idempotent: the whole bundle is fetched and placed every time, and every type it does
|
|
639
|
+
* not carry is undone, whether or not a previous sync placed it. Nothing on disk changes
|
|
640
|
+
* until the service has answered, so an unreachable service leaves every file as it was.
|
|
641
|
+
* Each type stands alone: a file that is not ours to touch stops its own type only.
|
|
642
|
+
*/
|
|
643
|
+
const sync = async (config) => {
|
|
644
|
+
const identity = {
|
|
645
|
+
clientId: clientId(config.home),
|
|
646
|
+
key: config.key
|
|
647
|
+
};
|
|
648
|
+
const bundle = await fetchBundle(config, identity.clientId);
|
|
649
|
+
const known = bundle.credentials.filter((credential) => isKnownType(credential.type));
|
|
650
|
+
const report = {
|
|
651
|
+
placed: new Array(),
|
|
652
|
+
removed: new Array(),
|
|
653
|
+
skipped: bundle.credentials.flatMap(({ type }) => isKnownType(type) ? [] : [type]),
|
|
654
|
+
failed: new Array()
|
|
655
|
+
};
|
|
656
|
+
for (const type of knownTypes) try {
|
|
657
|
+
const outcome = settle(config, identity, known, type);
|
|
658
|
+
if (outcome !== void 0) report[outcome].push(type);
|
|
659
|
+
} catch (error) {
|
|
660
|
+
report.failed.push({
|
|
661
|
+
type,
|
|
662
|
+
message: error instanceof Error ? error.message : String(error)
|
|
663
|
+
});
|
|
664
|
+
}
|
|
665
|
+
return report;
|
|
666
|
+
};
|
|
667
|
+
//#endregion
|
|
668
|
+
//#region src/token.ts
|
|
669
|
+
/** Codex's own margin: a token with less than this left is refreshed before it is used. */
|
|
670
|
+
const margin = 3e5;
|
|
671
|
+
/** The token's own expiry, unverified: it only decides when to ask the service again. */
|
|
672
|
+
const remaining = (jwt) => (claimsOf(jwt).exp ?? 0) * 1e3 - Date.now();
|
|
673
|
+
/**
|
|
674
|
+
* pi runs this on every model call, so the common path is one small file read. The token is
|
|
675
|
+
* the client's own copy, placed by sync, so no other tool's file is read or written. Near
|
|
676
|
+
* expiry, or with no copy, the service is asked again; an outage does not take a
|
|
677
|
+
* still-valid token away.
|
|
678
|
+
*/
|
|
679
|
+
const accessToken = async (config) => {
|
|
680
|
+
const file = accessTokenFile(config.home);
|
|
681
|
+
const held = readText(file)?.trim();
|
|
682
|
+
if (held && remaining(held) > margin) return held;
|
|
683
|
+
try {
|
|
684
|
+
const { accessToken: fresh } = await fetchCodexLogin(config, clientId(config.home));
|
|
685
|
+
if (held && readText(file)?.trim() === held) writeAtomic(file, `${fresh}\n`);
|
|
686
|
+
return fresh;
|
|
687
|
+
} catch (error) {
|
|
688
|
+
if (error instanceof Unreachable && held && remaining(held) > 0) return held;
|
|
689
|
+
throw error;
|
|
690
|
+
}
|
|
691
|
+
};
|
|
692
|
+
//#endregion
|
|
693
|
+
//#region src/main.ts
|
|
694
|
+
const usage = `Usage: clankercreds <command>
|
|
695
|
+
|
|
696
|
+
sync Fetch this machine's bundle and place each credential where its tool expects it.
|
|
697
|
+
token Print the current ChatGPT access token, for pi.
|
|
698
|
+
`;
|
|
699
|
+
const runSync = async () => {
|
|
700
|
+
const report = await sync(loadConfig());
|
|
701
|
+
for (const type of report.skipped) console.error(`warning: skipped unknown credential type "${type}"; update clankercreds`);
|
|
702
|
+
for (const type of report.placed) console.log(`placed ${type}`);
|
|
703
|
+
for (const type of report.removed) console.log(`removed ${type}`);
|
|
704
|
+
for (const { type, message } of report.failed) console.error(`error: ${type}: ${message}`);
|
|
705
|
+
if (report.failed.length > 0) process.exitCode = 1;
|
|
706
|
+
else if (report.placed.length === 0 && report.removed.length === 0) console.log("nothing to place");
|
|
707
|
+
};
|
|
708
|
+
/** Only the token reaches stdout: pi takes the whole output as the key. */
|
|
709
|
+
const runToken = async () => {
|
|
710
|
+
process.stdout.write(`${await accessToken(loadConfig())}\n`);
|
|
711
|
+
};
|
|
712
|
+
const commandFor = (name) => {
|
|
713
|
+
switch (name) {
|
|
714
|
+
case "sync": return runSync;
|
|
715
|
+
case "token": return runToken;
|
|
716
|
+
default: return;
|
|
717
|
+
}
|
|
718
|
+
};
|
|
719
|
+
const main = async () => {
|
|
720
|
+
const command = commandFor(process.argv[2]);
|
|
721
|
+
if (command === void 0) {
|
|
722
|
+
console.error(usage);
|
|
723
|
+
process.exitCode = 2;
|
|
724
|
+
return;
|
|
725
|
+
}
|
|
726
|
+
try {
|
|
727
|
+
await command();
|
|
728
|
+
} catch (error) {
|
|
729
|
+
if (error instanceof Unreachable) {
|
|
730
|
+
console.error(`error: the service could not be reached; existing files were kept`);
|
|
731
|
+
console.error(` ${error.message}`);
|
|
732
|
+
} else if (error instanceof Error) console.error(`error: ${error.message}`);
|
|
733
|
+
else throw error;
|
|
734
|
+
process.exitCode = 1;
|
|
735
|
+
}
|
|
736
|
+
};
|
|
737
|
+
await main();
|
|
738
|
+
//#endregion
|
|
739
|
+
export {};
|
package/package.json
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@gjermundgaraba/clankercreds",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "The machine client of clankercreds: fetches a bundle of credentials and places each where its tool expects it.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"repository": {
|
|
7
|
+
"type": "git",
|
|
8
|
+
"url": "git+https://github.com/gjermundgaraba/clankercreds.git",
|
|
9
|
+
"directory": "apps/cli"
|
|
10
|
+
},
|
|
11
|
+
"bin": {
|
|
12
|
+
"clankercreds": "./dist/main.mjs"
|
|
13
|
+
},
|
|
14
|
+
"files": [
|
|
15
|
+
"dist"
|
|
16
|
+
],
|
|
17
|
+
"type": "module",
|
|
18
|
+
"publishConfig": {
|
|
19
|
+
"access": "public"
|
|
20
|
+
},
|
|
21
|
+
"dependencies": {
|
|
22
|
+
"@gjermundgaraba/effect-actions": "0.7.0",
|
|
23
|
+
"effect": "4.0.0-rc.117",
|
|
24
|
+
"yaml": "2.9.1"
|
|
25
|
+
},
|
|
26
|
+
"devDependencies": {
|
|
27
|
+
"@clankercreds/api": "0.0.0",
|
|
28
|
+
"@gjermundgaraba/clankerauth-sdk": "0.9.0",
|
|
29
|
+
"@types/node": "26.6.2",
|
|
30
|
+
"typescript": "7.0.2",
|
|
31
|
+
"vite": "npm:@voidzero-dev/vite-plus-core@1.0.0-rc.1",
|
|
32
|
+
"vite-plus": "1.0.0-rc.1"
|
|
33
|
+
},
|
|
34
|
+
"engines": {
|
|
35
|
+
"node": ">=26"
|
|
36
|
+
},
|
|
37
|
+
"scripts": {
|
|
38
|
+
"test": "vp test --passWithNoTests",
|
|
39
|
+
"check": "vp check",
|
|
40
|
+
"build": "vp pack"
|
|
41
|
+
}
|
|
42
|
+
}
|