warpmetal 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,65 @@
1
+ import { cp, mkdir, rm, stat } from "node:fs/promises";
2
+ import { homedir } from "node:os";
3
+ import { dirname, join, resolve } from "node:path";
4
+ import { fileURLToPath } from "node:url";
5
+
6
+ import { CliError } from "./errors.js";
7
+
8
+ const SOURCE = fileURLToPath(new URL("../skills/warpmetal", import.meta.url));
9
+
10
+ async function exists(path) {
11
+ try {
12
+ await stat(path);
13
+ return true;
14
+ } catch (error) {
15
+ if (error?.code === "ENOENT") return false;
16
+ throw error;
17
+ }
18
+ }
19
+
20
+ function destinationFor(target, scope, { cwd, env }) {
21
+ const userHome = env.HOME || env.USERPROFILE || homedir();
22
+ if (target === "codex") {
23
+ if (scope === "project") return join(cwd, ".codex", "skills", "warpmetal");
24
+ return join(resolve(env.CODEX_HOME || join(userHome, ".codex")), "skills", "warpmetal");
25
+ }
26
+ if (target === "claude") {
27
+ if (scope === "project") return join(cwd, ".claude", "skills", "warpmetal");
28
+ return join(userHome, ".claude", "skills", "warpmetal");
29
+ }
30
+ throw new CliError(`Unsupported agent target: ${target}`, { exitCode: 2 });
31
+ }
32
+
33
+ export async function installSkill(
34
+ target,
35
+ { scope = "user", force = false, cwd = process.cwd(), env = process.env } = {},
36
+ ) {
37
+ if (!["user", "project"].includes(scope)) {
38
+ throw new CliError("--scope must be user or project.", { exitCode: 2 });
39
+ }
40
+ const targets = target === "all" ? ["codex", "claude"] : [target];
41
+ const destinations = targets.map((name) => ({
42
+ target: name,
43
+ path: destinationFor(name, scope, { cwd, env }),
44
+ }));
45
+
46
+ if (!force) {
47
+ for (const destination of destinations) {
48
+ if (await exists(destination.path)) {
49
+ throw new CliError(
50
+ `A WarpMetal skill already exists for ${destination.target}: ${destination.path}. Use --force to replace it.`,
51
+ { exitCode: 2 },
52
+ );
53
+ }
54
+ }
55
+ }
56
+
57
+ for (const destination of destinations) {
58
+ if (force && (await exists(destination.path))) {
59
+ await rm(destination.path, { recursive: true, force: true });
60
+ }
61
+ await mkdir(dirname(destination.path), { recursive: true });
62
+ await cp(SOURCE, destination.path, { recursive: true, errorOnExist: true });
63
+ }
64
+ return destinations;
65
+ }
package/src/ssh.js ADDED
@@ -0,0 +1,57 @@
1
+ import { readFile, rm, writeFile } from "node:fs/promises";
2
+ import { tmpdir } from "node:os";
3
+ import { join, resolve } from "node:path";
4
+ import { spawnSync } from "node:child_process";
5
+ import { mkdtemp } from "node:fs/promises";
6
+
7
+ import { CliError } from "./errors.js";
8
+
9
+ export async function readSshPublicKey(path) {
10
+ const resolved = resolve(path);
11
+ const value = (await readFile(resolved, "utf8")).trim();
12
+ if (value.includes("PRIVATE KEY") || !/^(ssh-rsa|ssh-ed25519|ecdsa-sha2-nistp(256|384|521))\s+/.test(value)) {
13
+ throw new CliError(
14
+ "The SSH public-key file must contain one supported OpenSSH public key and never a private key.",
15
+ { exitCode: 2 },
16
+ );
17
+ }
18
+ if (value.includes("\n")) {
19
+ throw new CliError("The SSH public-key file must contain exactly one key.", { exitCode: 2 });
20
+ }
21
+ return value;
22
+ }
23
+
24
+ export async function signSshChallenge(payload, identityPath, { spawn = spawnSync } = {}) {
25
+ if (typeof payload !== "string" || !payload.startsWith("warpmetal-ssh-auth-v1\n")) {
26
+ throw new CliError("WarpMetal returned an invalid SSH signing payload.");
27
+ }
28
+ const identity = resolve(identityPath);
29
+ const directory = await mkdtemp(join(tmpdir(), "warpmetal-ssh-"));
30
+ const payloadPath = join(directory, "challenge.txt");
31
+ const signaturePath = `${payloadPath}.sig`;
32
+ try {
33
+ await writeFile(payloadPath, payload, { encoding: "utf8", mode: 0o600, flag: "wx" });
34
+ const result = spawn(
35
+ "ssh-keygen",
36
+ ["-Y", "sign", "-f", identity, "-n", "warpmetal", payloadPath],
37
+ { stdio: ["inherit", "ignore", "inherit"] },
38
+ );
39
+ if (result.error?.code === "ENOENT") {
40
+ throw new CliError("ssh-keygen is required for WarpMetal SSH proof.", { exitCode: 2 });
41
+ }
42
+ if (result.error) throw result.error;
43
+ if (result.status !== 0) {
44
+ throw new CliError("ssh-keygen did not create a WarpMetal SSH signature.", { exitCode: 4 });
45
+ }
46
+ const signature = await readFile(signaturePath, "utf8");
47
+ if (
48
+ !signature.includes("-----BEGIN SSH SIGNATURE-----") ||
49
+ !signature.includes("-----END SSH SIGNATURE-----")
50
+ ) {
51
+ throw new CliError("ssh-keygen returned an invalid SSH signature.", { exitCode: 4 });
52
+ }
53
+ return signature;
54
+ } finally {
55
+ await rm(directory, { recursive: true, force: true });
56
+ }
57
+ }
package/src/state.js ADDED
@@ -0,0 +1,200 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { chmod, mkdir, open, readFile, rename, stat } from "node:fs/promises";
3
+ import { homedir } from "node:os";
4
+ import { dirname, isAbsolute, join, resolve } from "node:path";
5
+
6
+ import { CliError } from "./errors.js";
7
+
8
+ const STATE_VERSION = 1;
9
+
10
+ function emptyState() {
11
+ return {
12
+ version: STATE_VERSION,
13
+ orders: {},
14
+ servers: {},
15
+ operations: {},
16
+ };
17
+ }
18
+
19
+ export function resolveStateDirectory({ env = process.env, platform = process.platform } = {}) {
20
+ if (env.WARPMETAL_HOME) return resolve(env.WARPMETAL_HOME);
21
+ if (platform === "win32" && env.APPDATA) return join(env.APPDATA, "WarpMetal");
22
+ if (env.XDG_CONFIG_HOME) return join(env.XDG_CONFIG_HOME, "warpmetal");
23
+ return join(homedir(), ".config", "warpmetal");
24
+ }
25
+
26
+ async function ensurePrivateDirectory(path) {
27
+ await mkdir(path, { recursive: true, mode: 0o700 });
28
+ if (process.platform !== "win32") await chmod(path, 0o700);
29
+ }
30
+
31
+ async function atomicWriteJson(path, value) {
32
+ await ensurePrivateDirectory(dirname(path));
33
+ const temporary = `${path}.${process.pid}.${randomUUID()}.tmp`;
34
+ const handle = await open(temporary, "wx", 0o600);
35
+ try {
36
+ await handle.writeFile(`${JSON.stringify(value, null, 2)}\n`, "utf8");
37
+ await handle.sync();
38
+ } finally {
39
+ await handle.close();
40
+ }
41
+ await rename(temporary, path);
42
+ if (process.platform !== "win32") await chmod(path, 0o600);
43
+ }
44
+
45
+ function validateState(value) {
46
+ if (!value || value.version !== STATE_VERSION) {
47
+ throw new CliError("Unsupported or corrupt WarpMetal state file.", { exitCode: 2 });
48
+ }
49
+ if (!value.orders || !value.servers || !value.operations) {
50
+ throw new CliError("Incomplete WarpMetal state file.", { exitCode: 2 });
51
+ }
52
+ return value;
53
+ }
54
+
55
+ export class StateStore {
56
+ constructor(directory = resolveStateDirectory()) {
57
+ this.directory = isAbsolute(directory) ? directory : resolve(directory);
58
+ this.path = join(this.directory, "state.json");
59
+ }
60
+
61
+ async read() {
62
+ try {
63
+ return validateState(JSON.parse(await readFile(this.path, "utf8")));
64
+ } catch (error) {
65
+ if (error?.code === "ENOENT") return emptyState();
66
+ if (error instanceof SyntaxError) {
67
+ throw new CliError(`WarpMetal state is not valid JSON: ${this.path}`, { exitCode: 2 });
68
+ }
69
+ throw error;
70
+ }
71
+ }
72
+
73
+ async write(state) {
74
+ await atomicWriteJson(this.path, validateState(state));
75
+ }
76
+
77
+ async update(mutator) {
78
+ const state = await this.read();
79
+ const result = await mutator(state);
80
+ await this.write(state);
81
+ return result;
82
+ }
83
+
84
+ async savePreparedOrder(response, checkoutBody) {
85
+ const { task, ownerToken } = response;
86
+ if (!task?.id || !task?.serverId || !ownerToken) {
87
+ throw new CliError("WarpMetal returned an incomplete prepared order.");
88
+ }
89
+ await this.update((state) => {
90
+ state.orders[task.id] = {
91
+ taskId: task.id,
92
+ serverId: task.serverId,
93
+ planId: task.planId,
94
+ checkoutPath: task.checkoutPath,
95
+ checkoutBody,
96
+ ownerToken,
97
+ createdAt: new Date().toISOString(),
98
+ };
99
+ state.servers[task.serverId] = {
100
+ ...(state.servers[task.serverId] || {}),
101
+ serverId: task.serverId,
102
+ taskId: task.id,
103
+ ownerToken,
104
+ };
105
+ });
106
+ }
107
+
108
+ async savePaymentChallenge(taskId, { paymentRequired, paymentAttemptId }) {
109
+ await this.update((state) => {
110
+ const order = state.orders[taskId];
111
+ if (!order) throw new CliError(`No local order state exists for ${taskId}.`, { exitCode: 2 });
112
+ order.paymentRequired = paymentRequired;
113
+ order.paymentAttemptId = paymentAttemptId;
114
+ order.paymentChallengeSavedAt = new Date().toISOString();
115
+ });
116
+ }
117
+
118
+ async saveAccessToken(serverId, accessToken, expiresAt) {
119
+ await this.update((state) => {
120
+ const server = state.servers[serverId] || { serverId };
121
+ server.accessToken = accessToken;
122
+ server.accessTokenExpiresAt = expiresAt;
123
+ state.servers[serverId] = server;
124
+ });
125
+ }
126
+
127
+ async saveOperation(operationId, serverId, kind) {
128
+ await this.update((state) => {
129
+ state.operations[operationId] = {
130
+ operationId,
131
+ serverId,
132
+ kind,
133
+ createdAt: new Date().toISOString(),
134
+ };
135
+ });
136
+ }
137
+
138
+ async order(taskId) {
139
+ return (await this.read()).orders[taskId];
140
+ }
141
+
142
+ async server(serverId) {
143
+ return (await this.read()).servers[serverId];
144
+ }
145
+
146
+ async operation(operationId) {
147
+ return (await this.read()).operations[operationId];
148
+ }
149
+
150
+ async taskToken(taskId, env = process.env) {
151
+ if (env.WARPMETAL_OWNER_TOKEN) return env.WARPMETAL_OWNER_TOKEN;
152
+ return (await this.order(taskId))?.ownerToken;
153
+ }
154
+
155
+ async serverToken(serverId, env = process.env) {
156
+ if (env.WARPMETAL_ACCESS_TOKEN) return env.WARPMETAL_ACCESS_TOKEN;
157
+ if (env.WARPMETAL_OWNER_TOKEN) return env.WARPMETAL_OWNER_TOKEN;
158
+ const server = await this.server(serverId);
159
+ if (!server) return undefined;
160
+ if (
161
+ server.accessToken &&
162
+ server.accessTokenExpiresAt &&
163
+ Date.parse(server.accessTokenExpiresAt) > Date.now() + 5_000
164
+ ) {
165
+ return server.accessToken;
166
+ }
167
+ return server.ownerToken;
168
+ }
169
+
170
+ async summary() {
171
+ const state = await this.read();
172
+ return {
173
+ stateFile: this.path,
174
+ orders: Object.values(state.orders).map((order) => ({
175
+ taskId: order.taskId,
176
+ serverId: order.serverId,
177
+ planId: order.planId,
178
+ paymentAttemptId: order.paymentAttemptId,
179
+ credentialStored: Boolean(order.ownerToken),
180
+ })),
181
+ servers: Object.values(state.servers).map((server) => ({
182
+ serverId: server.serverId,
183
+ taskId: server.taskId,
184
+ recoveryCredentialStored: Boolean(server.ownerToken),
185
+ accessTokenExpiresAt: server.accessTokenExpiresAt,
186
+ })),
187
+ operations: Object.values(state.operations),
188
+ };
189
+ }
190
+
191
+ async permissions() {
192
+ try {
193
+ const info = await stat(this.path);
194
+ return info.mode & 0o777;
195
+ } catch (error) {
196
+ if (error?.code === "ENOENT") return undefined;
197
+ throw error;
198
+ }
199
+ }
200
+ }