recess-cli 1.9.2 → 2.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,100 @@
1
+ import fs from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { flagNumber, flagString } from "../args.js";
4
+ import { CliError } from "../errors.js";
5
+ export function positional(parsed, index, label) {
6
+ const value = parsed.positionals[index];
7
+ if (!value) {
8
+ throw new CliError("invalid_arguments", `Missing ${label}.`);
9
+ }
10
+ return value;
11
+ }
12
+ export function assertChoice(value, choices, label) {
13
+ if (!choices.includes(value)) {
14
+ throw new CliError("invalid_arguments", `${label} must be one of: ${choices.join(", ")}.`);
15
+ }
16
+ return value;
17
+ }
18
+ export function flagIdList(parsed, name) {
19
+ const raw = flagString(parsed, name);
20
+ if (raw === undefined)
21
+ return [];
22
+ const ids = raw
23
+ .split(",")
24
+ .map((value) => value.trim())
25
+ .filter(Boolean);
26
+ if (ids.length === 0) {
27
+ throw new CliError("invalid_arguments", `--${name} requires a value.`);
28
+ }
29
+ return ids;
30
+ }
31
+ export function flagBooleanValue(parsed, name) {
32
+ const raw = flagString(parsed, name);
33
+ if (raw === undefined)
34
+ return undefined;
35
+ if (raw === "true")
36
+ return true;
37
+ if (raw === "false")
38
+ return false;
39
+ throw new CliError("invalid_arguments", `--${name} must be true or false.`);
40
+ }
41
+ export function flagInteger(parsed, name, options = {}) {
42
+ const value = flagNumber(parsed, name);
43
+ if (value === undefined) {
44
+ if (options.required) {
45
+ throw new CliError("invalid_arguments", `Missing required --${name}.`);
46
+ }
47
+ return undefined;
48
+ }
49
+ if (!Number.isInteger(value) ||
50
+ (options.min !== undefined && value < options.min) ||
51
+ (options.max !== undefined && value > options.max)) {
52
+ const bounds = options.min !== undefined && options.max !== undefined
53
+ ? ` from ${options.min} through ${options.max}`
54
+ : options.min !== undefined
55
+ ? ` of at least ${options.min}`
56
+ : options.max !== undefined
57
+ ? ` no greater than ${options.max}`
58
+ : "";
59
+ throw new CliError("invalid_arguments", `--${name} must be an integer${bounds}.`);
60
+ }
61
+ return value;
62
+ }
63
+ export function flagIsoInstant(parsed, name, options = {}) {
64
+ const raw = flagString(parsed, name, options);
65
+ if (raw === undefined)
66
+ return undefined;
67
+ if (!/^\d{4}-\d{2}-\d{2}T/.test(raw) || Number.isNaN(Date.parse(raw))) {
68
+ throw new CliError("invalid_arguments", `--${name} must be a full ISO-8601 datetime with a timezone.`);
69
+ }
70
+ return raw;
71
+ }
72
+ export async function readJsonValue(filePath, label) {
73
+ const absolutePath = path.resolve(filePath);
74
+ let raw;
75
+ try {
76
+ raw = await fs.readFile(absolutePath, "utf8");
77
+ }
78
+ catch (error) {
79
+ if (error.code === "ENOENT") {
80
+ throw new CliError("invalid_arguments", `${label} does not exist: ${absolutePath}`);
81
+ }
82
+ throw error;
83
+ }
84
+ let parsed;
85
+ try {
86
+ parsed = JSON.parse(raw);
87
+ }
88
+ catch (error) {
89
+ throw new CliError("invalid_arguments", `${label} is not valid JSON (${absolutePath}): ${error instanceof Error ? error.message : String(error)}`);
90
+ }
91
+ return { absolutePath, raw, parsed };
92
+ }
93
+ export async function readJsonFile(filePath, label) {
94
+ const { absolutePath, parsed } = await readJsonValue(filePath, label);
95
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
96
+ throw new CliError("invalid_arguments", `${label} must be a JSON object (${absolutePath}).`);
97
+ }
98
+ return parsed;
99
+ }
100
+ //# sourceMappingURL=shared.js.map
package/dist/config.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import fs from "node:fs/promises";
2
2
  import os from "node:os";
3
3
  import path from "node:path";
4
+ import { CliError } from "./errors.js";
4
5
  export const DEFAULT_OAUTH_CLIENT_ID = "c7e34138-18f9-45b1-a2fb-26a4e3a6d739";
5
6
  export function defaultConfigPath() {
6
7
  return (process.env.RECESS_CLI_CONFIG ??
@@ -16,32 +17,111 @@ export async function readStoredConfig(configPath = defaultConfigPath()) {
16
17
  throw error;
17
18
  }
18
19
  }
19
- export async function resolveConfig() {
20
+ export async function resolveConfig(profileName) {
20
21
  const configPath = defaultConfigPath();
21
22
  const stored = await readStoredConfig(configPath);
23
+ const selectedProfileName = profileName ?? process.env.RECESS_CLI_PROFILE ?? stored.activeProfile;
24
+ const profile = selectedProfileName
25
+ ? stored.profiles?.[selectedProfileName]
26
+ : undefined;
27
+ if (selectedProfileName && !profile) {
28
+ throw new CliError("unknown_profile", `Unknown Recess CLI profile ${selectedProfileName}. Run \`recess --json profile list\`.`);
29
+ }
22
30
  const envCookie = process.env.RECESS_CLI_COOKIE;
23
- const sessionCookie = envCookie ?? stored.sessionCookie;
31
+ const sessionCookie = envCookie ??
32
+ (selectedProfileName ? profile?.sessionCookie : stored.sessionCookie);
24
33
  return {
25
34
  configPath,
26
35
  apiOrigin: process.env.RECESS_CLI_API_ORIGIN ??
36
+ profile?.apiOrigin ??
27
37
  stored.apiOrigin ??
28
38
  "https://api.recess.gg",
29
39
  webOrigin: process.env.RECESS_CLI_WEB_ORIGIN ??
40
+ profile?.webOrigin ??
30
41
  stored.webOrigin ??
31
42
  "https://recess.gg",
32
43
  oauthClientId: process.env.RECESS_CLI_OAUTH_CLIENT_ID ??
44
+ profile?.oauthClientId ??
33
45
  stored.oauthClientId ??
34
46
  DEFAULT_OAUTH_CLIENT_ID,
35
47
  sessionCookie,
36
- sessionExpiresAt: stored.sessionExpiresAt,
37
- user: stored.user,
48
+ sessionExpiresAt: selectedProfileName
49
+ ? profile?.sessionExpiresAt
50
+ : stored.sessionExpiresAt,
51
+ user: selectedProfileName ? profile?.user : stored.user,
38
52
  authSource: envCookie ? "env" : sessionCookie ? "config" : "missing",
39
- pendingDeviceCode: stored.pendingDeviceCode,
40
- pendingUserCode: stored.pendingUserCode,
41
- pendingApprovalUrl: stored.pendingApprovalUrl,
42
- pendingExpiresAt: stored.pendingExpiresAt,
53
+ pendingDeviceCode: selectedProfileName
54
+ ? profile?.pendingDeviceCode
55
+ : stored.pendingDeviceCode,
56
+ pendingUserCode: selectedProfileName
57
+ ? profile?.pendingUserCode
58
+ : stored.pendingUserCode,
59
+ pendingApprovalUrl: selectedProfileName
60
+ ? profile?.pendingApprovalUrl
61
+ : stored.pendingApprovalUrl,
62
+ pendingExpiresAt: selectedProfileName
63
+ ? profile?.pendingExpiresAt
64
+ : stored.pendingExpiresAt,
65
+ profileName: selectedProfileName,
66
+ };
67
+ }
68
+ export function assertProfileName(name) {
69
+ const normalized = name.trim();
70
+ if (!/^[a-z0-9][a-z0-9_-]{0,63}$/i.test(normalized)) {
71
+ throw new CliError("invalid_arguments", "Profile names must be 1-64 letters, numbers, underscores, or hyphens.");
72
+ }
73
+ return normalized;
74
+ }
75
+ export async function listProfiles() {
76
+ const stored = await readStoredConfig();
77
+ return {
78
+ activeProfile: stored.activeProfile ?? null,
79
+ profiles: Object.entries(stored.profiles ?? {})
80
+ .sort(([left], [right]) => left.localeCompare(right))
81
+ .map(([name, config]) => ({
82
+ name,
83
+ config: {
84
+ apiOrigin: config.apiOrigin,
85
+ webOrigin: config.webOrigin,
86
+ oauthClientId: config.oauthClientId,
87
+ },
88
+ })),
43
89
  };
44
90
  }
91
+ export async function saveProfile(name, profile) {
92
+ const normalized = assertProfileName(name);
93
+ const stored = await readStoredConfig();
94
+ await writeStoredConfig({
95
+ ...stored,
96
+ profiles: {
97
+ ...stored.profiles,
98
+ [normalized]: {
99
+ ...stored.profiles?.[normalized],
100
+ ...profile,
101
+ },
102
+ },
103
+ });
104
+ }
105
+ export async function useProfile(name) {
106
+ const normalized = assertProfileName(name);
107
+ const stored = await readStoredConfig();
108
+ if (!stored.profiles?.[normalized]) {
109
+ throw new CliError("unknown_profile", `Unknown Recess CLI profile ${normalized}. Run \`recess --json profile list\`.`);
110
+ }
111
+ await writeStoredConfig({ ...stored, activeProfile: normalized });
112
+ }
113
+ export async function deleteProfile(name) {
114
+ const normalized = assertProfileName(name);
115
+ const stored = await readStoredConfig();
116
+ if (!stored.profiles?.[normalized]) {
117
+ throw new CliError("unknown_profile", `Unknown Recess CLI profile ${normalized}.`);
118
+ }
119
+ const profiles = { ...stored.profiles };
120
+ delete profiles[normalized];
121
+ if (stored.activeProfile === normalized)
122
+ delete stored.activeProfile;
123
+ await writeStoredConfig({ ...stored, profiles });
124
+ }
45
125
  export async function writeStoredConfig(config, configPath = defaultConfigPath()) {
46
126
  const directory = path.dirname(configPath);
47
127
  await fs.mkdir(directory, { recursive: true, mode: 0o700 });
@@ -52,28 +132,45 @@ export async function writeStoredConfig(config, configPath = defaultConfigPath()
52
132
  await fs.rename(temporary, configPath);
53
133
  await fs.chmod(configPath, 0o600);
54
134
  }
55
- export async function updateStoredConfig(update) {
135
+ export async function updateStoredConfig(update, profileName) {
56
136
  const configPath = defaultConfigPath();
57
137
  const current = await readStoredConfig(configPath);
58
- const next = { ...current, ...update };
138
+ const next = profileName
139
+ ? {
140
+ ...current,
141
+ profiles: {
142
+ ...current.profiles,
143
+ [assertProfileName(profileName)]: {
144
+ ...current.profiles?.[profileName],
145
+ ...update,
146
+ },
147
+ },
148
+ }
149
+ : { ...current, ...update };
59
150
  await writeStoredConfig(next, configPath);
60
151
  return next;
61
152
  }
62
- export async function clearStoredSession() {
153
+ export async function clearStoredSession(profileName) {
63
154
  const configPath = defaultConfigPath();
64
155
  const current = await readStoredConfig(configPath);
65
- delete current.sessionCookie;
66
- delete current.sessionExpiresAt;
67
- delete current.user;
156
+ const target = profileName ? current.profiles?.[profileName] : current;
157
+ if (!target)
158
+ return;
159
+ delete target.sessionCookie;
160
+ delete target.sessionExpiresAt;
161
+ delete target.user;
68
162
  await writeStoredConfig(current, configPath);
69
163
  }
70
- export async function clearPendingDeviceAuth() {
164
+ export async function clearPendingDeviceAuth(profileName) {
71
165
  const configPath = defaultConfigPath();
72
166
  const current = await readStoredConfig(configPath);
73
- delete current.pendingDeviceCode;
74
- delete current.pendingUserCode;
75
- delete current.pendingApprovalUrl;
76
- delete current.pendingExpiresAt;
167
+ const target = profileName ? current.profiles?.[profileName] : current;
168
+ if (!target)
169
+ return;
170
+ delete target.pendingDeviceCode;
171
+ delete target.pendingUserCode;
172
+ delete target.pendingApprovalUrl;
173
+ delete target.pendingExpiresAt;
77
174
  await writeStoredConfig(current, configPath);
78
175
  }
79
176
  //# sourceMappingURL=config.js.map
@@ -0,0 +1,26 @@
1
+ import fs from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { CliError } from "./errors.js";
4
+ export async function deliverJson(destination, envelope) {
5
+ if (!destination || destination === "stdout")
6
+ return { delivered: false };
7
+ if (!destination.startsWith("file:")) {
8
+ throw new CliError("invalid_delivery", "--deliver must be one of: stdout, file:<path>. Webhook delivery is deliberately unavailable for authenticated Recess data.");
9
+ }
10
+ const requestedPath = destination.slice("file:".length);
11
+ if (!requestedPath) {
12
+ throw new CliError("invalid_delivery", "file: delivery requires a path.");
13
+ }
14
+ const absolutePath = path.resolve(requestedPath);
15
+ const bytes = Buffer.from(`${JSON.stringify(envelope)}\n`, "utf8");
16
+ await fs.mkdir(path.dirname(absolutePath), { recursive: true, mode: 0o700 });
17
+ const temporary = `${absolutePath}.${process.pid}.tmp`;
18
+ await fs.writeFile(temporary, bytes, { mode: 0o600 });
19
+ await fs.rename(temporary, absolutePath);
20
+ return {
21
+ delivered: true,
22
+ deliveredTo: `file:${absolutePath}`,
23
+ bytes: bytes.byteLength,
24
+ };
25
+ }
26
+ //# sourceMappingURL=delivery.js.map
@@ -0,0 +1,64 @@
1
+ import fs from "node:fs/promises";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+ import { CliError } from "./errors.js";
5
+ function feedbackPath() {
6
+ return (process.env.RECESS_CLI_FEEDBACK_PATH ??
7
+ path.join(os.homedir(), ".recess-cli", "feedback.jsonl"));
8
+ }
9
+ async function readFeedback() {
10
+ try {
11
+ return (await fs.readFile(feedbackPath(), "utf8"))
12
+ .split("\n")
13
+ .filter(Boolean)
14
+ .flatMap((line) => {
15
+ try {
16
+ return [JSON.parse(line)];
17
+ }
18
+ catch {
19
+ return [];
20
+ }
21
+ });
22
+ }
23
+ catch (error) {
24
+ if (error.code === "ENOENT")
25
+ return [];
26
+ throw error;
27
+ }
28
+ }
29
+ export async function listFeedback(limit = 20) {
30
+ const entries = (await readFeedback()).sort((left, right) => right.createdAt.localeCompare(left.createdAt));
31
+ return {
32
+ entries: entries.slice(0, limit),
33
+ truncated: entries.length > limit,
34
+ };
35
+ }
36
+ export async function submitFeedback(entry) {
37
+ const existing = (await readFeedback()).find((item) => item.id === entry.id);
38
+ if (existing)
39
+ return existing;
40
+ const endpoint = process.env.RECESS_CLI_FEEDBACK_ENDPOINT;
41
+ let upstreamStatus;
42
+ if (endpoint) {
43
+ const response = await fetch(endpoint, {
44
+ method: "POST",
45
+ headers: {
46
+ "content-type": "application/json",
47
+ "idempotency-key": entry.id,
48
+ },
49
+ body: JSON.stringify(entry),
50
+ signal: AbortSignal.timeout(10_000),
51
+ });
52
+ upstreamStatus = response.status;
53
+ if (!response.ok) {
54
+ throw new CliError("feedback_delivery_failed", `Feedback endpoint returned HTTP ${response.status}. Retry the unchanged command with the same operation key.`);
55
+ }
56
+ }
57
+ const saved = { ...entry, ...(upstreamStatus ? { upstreamStatus } : {}) };
58
+ const file = feedbackPath();
59
+ await fs.mkdir(path.dirname(file), { recursive: true, mode: 0o700 });
60
+ await fs.appendFile(file, `${JSON.stringify(saved)}\n`, { mode: 0o600 });
61
+ await fs.chmod(file, 0o600);
62
+ return saved;
63
+ }
64
+ //# sourceMappingURL=feedback.js.map