@zocomputer/cli 0.0.0-stage → 0.1.1
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/README.md +170 -2
- package/dist/argv.d.ts +15 -0
- package/dist/argv.js +83 -0
- package/dist/auth.d.ts +39 -0
- package/dist/auth.js +290 -0
- package/dist/catalog.d.ts +23 -0
- package/dist/catalog.js +193 -0
- package/dist/commands.d.ts +9 -0
- package/dist/commands.js +119 -0
- package/dist/context.d.ts +15 -0
- package/dist/context.js +30 -0
- package/dist/errors.d.ts +9 -0
- package/dist/errors.js +25 -0
- package/dist/flags.d.ts +27 -0
- package/dist/flags.js +211 -0
- package/dist/generated/commands.d.ts +23 -0
- package/dist/generated/commands.js +1751 -0
- package/dist/http.d.ts +11 -0
- package/dist/http.js +124 -0
- package/dist/login.d.ts +14 -0
- package/dist/login.js +102 -0
- package/dist/main.d.ts +15 -0
- package/dist/main.js +306 -0
- package/dist/output.d.ts +21 -0
- package/dist/output.js +221 -0
- package/dist/presentation.d.ts +9 -0
- package/dist/presentation.js +95 -0
- package/dist/profiles.d.ts +37 -0
- package/dist/profiles.js +123 -0
- package/dist/rpc.d.ts +26 -0
- package/dist/rpc.js +208 -0
- package/dist/session.d.ts +17 -0
- package/dist/session.js +48 -0
- package/dist/storage.d.ts +10 -0
- package/dist/storage.js +209 -0
- package/package.json +21 -4
package/dist/rpc.js
ADDED
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
import { createReadStream } from "node:fs";
|
|
2
|
+
import { addAbortSignal, Readable } from "node:stream";
|
|
3
|
+
import { createClient, SystemAPIError, } from "@zocomputer/system-api";
|
|
4
|
+
import { JsonError, JsonNumber, loadsJson, } from "@zocomputer/system-api/json";
|
|
5
|
+
import { compileSchema, SchemaValidationError, SchemaDefinitionError, } from "@zocomputer/system-api/validation";
|
|
6
|
+
import { apiBaseUrl, loadCatalog, resolveAction, } from "./catalog.js";
|
|
7
|
+
import { CliError } from "./errors.js";
|
|
8
|
+
import { workspaceId } from "./profiles.js";
|
|
9
|
+
const MAX_INPUT_BYTES = 5 * 1024 * 1024;
|
|
10
|
+
async function readInput(source, signal) {
|
|
11
|
+
const chunks = [];
|
|
12
|
+
let size = 0;
|
|
13
|
+
const stream = source instanceof Readable ? source : Readable.from(source);
|
|
14
|
+
if (signal)
|
|
15
|
+
addAbortSignal(signal, stream);
|
|
16
|
+
for await (const chunk of stream) {
|
|
17
|
+
const bytes = Buffer.from(chunk);
|
|
18
|
+
size += bytes.byteLength;
|
|
19
|
+
if (size > MAX_INPUT_BYTES)
|
|
20
|
+
throw new CliError("Input exceeds the 5 MiB request limit.");
|
|
21
|
+
chunks.push(bytes);
|
|
22
|
+
}
|
|
23
|
+
try {
|
|
24
|
+
return new TextDecoder("utf-8", { fatal: true }).decode(Buffer.concat(chunks));
|
|
25
|
+
}
|
|
26
|
+
catch {
|
|
27
|
+
throw new CliError("Input must be valid UTF-8 JSON.");
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
export function parseJsonObject(text, label = "Input") {
|
|
31
|
+
let value;
|
|
32
|
+
try {
|
|
33
|
+
value = loadsJson(text, { maxBytes: MAX_INPUT_BYTES });
|
|
34
|
+
}
|
|
35
|
+
catch (error) {
|
|
36
|
+
if (error instanceof JsonError)
|
|
37
|
+
throw new CliError(`${label}: ${error.message}`, 2, error.code);
|
|
38
|
+
throw error;
|
|
39
|
+
}
|
|
40
|
+
if (value === null ||
|
|
41
|
+
typeof value !== "object" ||
|
|
42
|
+
Array.isArray(value) ||
|
|
43
|
+
value instanceof JsonNumber)
|
|
44
|
+
throw new CliError(`${label} must be a JSON object.`);
|
|
45
|
+
return value;
|
|
46
|
+
}
|
|
47
|
+
export async function inputJson(options) {
|
|
48
|
+
if (options.input !== undefined && options.inputFile !== undefined)
|
|
49
|
+
throw new CliError("Use either --input or --input-file.");
|
|
50
|
+
let text;
|
|
51
|
+
if (options.input !== undefined)
|
|
52
|
+
text = options.input;
|
|
53
|
+
else if (options.inputFile !== undefined) {
|
|
54
|
+
try {
|
|
55
|
+
text = await readInput(options.inputFile === "-"
|
|
56
|
+
? (options.stdin ?? process.stdin)
|
|
57
|
+
: createReadStream(options.inputFile), options.signal);
|
|
58
|
+
}
|
|
59
|
+
catch (error) {
|
|
60
|
+
if (options.signal?.aborted)
|
|
61
|
+
throw new SystemAPIError({
|
|
62
|
+
code: "interrupted",
|
|
63
|
+
message: "Interrupted.",
|
|
64
|
+
outcome: "not_started",
|
|
65
|
+
});
|
|
66
|
+
if (error instanceof CliError)
|
|
67
|
+
throw error;
|
|
68
|
+
throw new CliError("Could not read --input-file.");
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
else
|
|
72
|
+
text = "{}";
|
|
73
|
+
parseJsonObject(text);
|
|
74
|
+
return text;
|
|
75
|
+
}
|
|
76
|
+
function compileContract(schema) {
|
|
77
|
+
try {
|
|
78
|
+
return compileSchema(schema);
|
|
79
|
+
}
|
|
80
|
+
catch (error) {
|
|
81
|
+
if (error instanceof SchemaDefinitionError)
|
|
82
|
+
throw new SystemAPIError({
|
|
83
|
+
code: "invalid_definition",
|
|
84
|
+
message: "The published contract cannot be validated by this CLI. Update @zocomputer/cli or inspect the action with zo actions describe.",
|
|
85
|
+
outcome: "not_started",
|
|
86
|
+
});
|
|
87
|
+
throw error;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
function validateInput(schema, value, label) {
|
|
91
|
+
try {
|
|
92
|
+
schema.validate(value, { applyDefaults: true });
|
|
93
|
+
}
|
|
94
|
+
catch (error) {
|
|
95
|
+
if (error instanceof SchemaValidationError)
|
|
96
|
+
throw new CliError(`${label} does not match the action contract: ${error.message}`, 2, label === "Input" ? "invalid_input" : "invalid_query");
|
|
97
|
+
throw error;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
export async function runRpc(context, options, loadedCatalog) {
|
|
101
|
+
workspaceId(context.workspaceId);
|
|
102
|
+
if (context.signal?.aborted)
|
|
103
|
+
throw new SystemAPIError({
|
|
104
|
+
code: "interrupted",
|
|
105
|
+
message: "Interrupted.",
|
|
106
|
+
outcome: "not_started",
|
|
107
|
+
});
|
|
108
|
+
const input = await inputJson({ ...options, signal: context.signal });
|
|
109
|
+
const query = options.query === undefined
|
|
110
|
+
? undefined
|
|
111
|
+
: parseJsonObject(options.query, "Query");
|
|
112
|
+
if (options.version !== undefined &&
|
|
113
|
+
(!Number.isInteger(options.version) ||
|
|
114
|
+
options.version < 1 ||
|
|
115
|
+
options.version > 2_147_483_647))
|
|
116
|
+
throw new CliError("--version must be a positive 32-bit integer.");
|
|
117
|
+
if (options.idempotencyKey !== undefined &&
|
|
118
|
+
!/^[\x21-\x7e]{1,255}$/.test(options.idempotencyKey))
|
|
119
|
+
throw new CliError("--idempotency-key must contain 1–255 visible ASCII characters without spaces.");
|
|
120
|
+
const { catalog, runtimeCatalog, authenticatedContext } = loadedCatalog ?? (await loadCatalog(context));
|
|
121
|
+
if (authenticatedContext.workspaceId !== context.workspaceId ||
|
|
122
|
+
catalog.selection?.workspace !== context.workspaceId ||
|
|
123
|
+
catalog.selection.catalog !== "scoped" ||
|
|
124
|
+
apiBaseUrl(authenticatedContext) !== apiBaseUrl(context))
|
|
125
|
+
throw new CliError("Invocation requires a scoped catalog for the selected workspace and environment.");
|
|
126
|
+
const contract = resolveAction(catalog, options.action, options.version);
|
|
127
|
+
if (contract.availability === "definition")
|
|
128
|
+
throw new CliError("This action is a definition without a callable contract in the selected workspace.", 2, "action_not_found");
|
|
129
|
+
if (options.idempotencyKey !== undefined &&
|
|
130
|
+
options.action.startsWith("zo.") &&
|
|
131
|
+
options.action !== "zo.function.invoke")
|
|
132
|
+
throw new CliError(`${options.action} does not accept an envelope idempotency key.`);
|
|
133
|
+
if (query !== undefined && contract.query === undefined)
|
|
134
|
+
throw new CliError(`${options.action} version ${contract.version} does not declare query options.`, 2, "invalid_query");
|
|
135
|
+
const inputSchema = compileContract(contract.input);
|
|
136
|
+
const outputSchema = compileContract(contract.output);
|
|
137
|
+
validateInput(inputSchema, parseJsonObject(input), "Input");
|
|
138
|
+
if (query !== undefined && contract.query !== undefined)
|
|
139
|
+
validateInput(compileContract(contract.query), query, "Query");
|
|
140
|
+
const wireOptions = JSON.stringify({
|
|
141
|
+
version: contract.version,
|
|
142
|
+
...(options.idempotencyKey !== undefined
|
|
143
|
+
? { idempotency_key: options.idempotencyKey }
|
|
144
|
+
: {}),
|
|
145
|
+
});
|
|
146
|
+
const requestJson = `{"action":${JSON.stringify(options.action)},"input":${input},"options":${wireOptions.slice(0, -1)}${options.query === undefined ? "" : `,"query":${options.query}`}}}`;
|
|
147
|
+
if (Buffer.byteLength(requestJson) > MAX_INPUT_BYTES)
|
|
148
|
+
throw new CliError("Request exceeds the 5 MiB request limit.");
|
|
149
|
+
if (context.signal?.aborted)
|
|
150
|
+
throw new SystemAPIError({
|
|
151
|
+
code: "interrupted",
|
|
152
|
+
message: "Interrupted.",
|
|
153
|
+
outcome: "not_started",
|
|
154
|
+
});
|
|
155
|
+
if (options.preview)
|
|
156
|
+
return { kind: "preview", workspaceId: context.workspaceId, requestJson };
|
|
157
|
+
// Snapshot calls may run for 210 seconds; leave time to receive that result.
|
|
158
|
+
const timeout = AbortSignal.timeout(240_000);
|
|
159
|
+
const signal = context.signal
|
|
160
|
+
? AbortSignal.any([context.signal, timeout])
|
|
161
|
+
: timeout;
|
|
162
|
+
const client = createClient({
|
|
163
|
+
catalog: runtimeCatalog,
|
|
164
|
+
baseUrl: apiBaseUrl(context),
|
|
165
|
+
workspaceId: context.workspaceId,
|
|
166
|
+
accessToken: authenticatedContext.accessToken,
|
|
167
|
+
fetch: (url, init) => (context.fetch ?? fetch)(url, { ...init, signal }),
|
|
168
|
+
validator: {
|
|
169
|
+
validateRequestBody() { },
|
|
170
|
+
validateSuccessBody(_target, text) {
|
|
171
|
+
const body = loadsJson(text, { maxDepth: 34, maxNodes: null });
|
|
172
|
+
if (body === null ||
|
|
173
|
+
typeof body !== "object" ||
|
|
174
|
+
Array.isArray(body) ||
|
|
175
|
+
body instanceof JsonNumber ||
|
|
176
|
+
body.result === undefined)
|
|
177
|
+
throw new Error("Response has no result.");
|
|
178
|
+
outputSchema.validate(body.result);
|
|
179
|
+
},
|
|
180
|
+
},
|
|
181
|
+
});
|
|
182
|
+
try {
|
|
183
|
+
return {
|
|
184
|
+
kind: "success",
|
|
185
|
+
response: await client.callJson(options.action, input, {
|
|
186
|
+
version: contract.version,
|
|
187
|
+
...(options.query !== undefined ? { queryJson: options.query } : {}),
|
|
188
|
+
...(options.idempotencyKey !== undefined
|
|
189
|
+
? { idempotencyKey: options.idempotencyKey }
|
|
190
|
+
: {}),
|
|
191
|
+
}),
|
|
192
|
+
};
|
|
193
|
+
}
|
|
194
|
+
catch (error) {
|
|
195
|
+
if (signal.aborted && error instanceof SystemAPIError)
|
|
196
|
+
throw new SystemAPIError({
|
|
197
|
+
code: context.signal?.aborted
|
|
198
|
+
? "interrupted"
|
|
199
|
+
: "execution_deadline_exceeded",
|
|
200
|
+
message: context.signal?.aborted
|
|
201
|
+
? "Interrupted."
|
|
202
|
+
: "The request deadline expired.",
|
|
203
|
+
requestId: error.requestId,
|
|
204
|
+
outcome: error.outcome ?? "unknown",
|
|
205
|
+
});
|
|
206
|
+
throw error;
|
|
207
|
+
}
|
|
208
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
export interface Subject {
|
|
2
|
+
type: "user" | "agent" | "function" | "service" | "machine" | "service_account" | "workflow";
|
|
3
|
+
id: string;
|
|
4
|
+
}
|
|
5
|
+
export interface Workspace {
|
|
6
|
+
workspace_id: string;
|
|
7
|
+
name: string;
|
|
8
|
+
subject: Subject;
|
|
9
|
+
}
|
|
10
|
+
export interface Session {
|
|
11
|
+
credential_kind: "sign_in" | "bearer";
|
|
12
|
+
approving_subject: Subject | null;
|
|
13
|
+
expires_at: string | null;
|
|
14
|
+
access_token_expires_at: string;
|
|
15
|
+
workspaces: Workspace[];
|
|
16
|
+
}
|
|
17
|
+
export declare function parseSession(raw: unknown): Session;
|
package/dist/session.js
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { CliError, record } from "./errors.js";
|
|
2
|
+
function subject(raw) {
|
|
3
|
+
if (!record(raw) ||
|
|
4
|
+
(raw.type !== "user" &&
|
|
5
|
+
raw.type !== "agent" &&
|
|
6
|
+
raw.type !== "function" &&
|
|
7
|
+
raw.type !== "service" &&
|
|
8
|
+
raw.type !== "machine" &&
|
|
9
|
+
raw.type !== "service_account" &&
|
|
10
|
+
raw.type !== "workflow") ||
|
|
11
|
+
typeof raw.id !== "string")
|
|
12
|
+
throw new CliError("Invalid session subject.", 5, "invalid_response");
|
|
13
|
+
return { type: raw.type, id: raw.id };
|
|
14
|
+
}
|
|
15
|
+
export function parseSession(raw) {
|
|
16
|
+
if (!record(raw) ||
|
|
17
|
+
(raw.credential_kind !== "sign_in" && raw.credential_kind !== "bearer") ||
|
|
18
|
+
(raw.expires_at !== null &&
|
|
19
|
+
(typeof raw.expires_at !== "string" ||
|
|
20
|
+
!Number.isFinite(Date.parse(raw.expires_at)))) ||
|
|
21
|
+
typeof raw.access_token_expires_at !== "string" ||
|
|
22
|
+
!Number.isFinite(Date.parse(raw.access_token_expires_at)) ||
|
|
23
|
+
!Array.isArray(raw.workspaces) ||
|
|
24
|
+
raw.workspaces.length > 10)
|
|
25
|
+
throw new CliError("Invalid session response.", 5, "invalid_response");
|
|
26
|
+
const workspaces = raw.workspaces.map((entry) => {
|
|
27
|
+
if (!record(entry) ||
|
|
28
|
+
typeof entry.workspace_id !== "string" ||
|
|
29
|
+
!/^wsp_[A-Za-z0-9_-]+$/.test(entry.workspace_id) ||
|
|
30
|
+
typeof entry.name !== "string")
|
|
31
|
+
throw new CliError("Invalid session workspace.", 5, "invalid_response");
|
|
32
|
+
return {
|
|
33
|
+
workspace_id: entry.workspace_id,
|
|
34
|
+
name: entry.name,
|
|
35
|
+
subject: subject(entry.subject),
|
|
36
|
+
};
|
|
37
|
+
});
|
|
38
|
+
if (new Set(workspaces.map((entry) => entry.workspace_id)).size !==
|
|
39
|
+
workspaces.length)
|
|
40
|
+
throw new CliError("Duplicate session workspace.", 5, "invalid_response");
|
|
41
|
+
return {
|
|
42
|
+
credential_kind: raw.credential_kind,
|
|
43
|
+
approving_subject: raw.approving_subject === null ? null : subject(raw.approving_subject),
|
|
44
|
+
expires_at: raw.expires_at,
|
|
45
|
+
access_token_expires_at: raw.access_token_expires_at,
|
|
46
|
+
workspaces,
|
|
47
|
+
};
|
|
48
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { CliError } from "./errors.ts";
|
|
2
|
+
export declare function configDirectory(env?: NodeJS.ProcessEnv): string;
|
|
3
|
+
export declare function privateDirectory(path: string): Promise<void>;
|
|
4
|
+
export declare function readPrivate(path: string): Promise<unknown | undefined>;
|
|
5
|
+
export declare class PrivateWriteDurabilityError extends CliError {
|
|
6
|
+
constructor();
|
|
7
|
+
}
|
|
8
|
+
export declare function writePrivate(path: string, value: unknown): Promise<void>;
|
|
9
|
+
export declare function removePrivate(path: string): Promise<void>;
|
|
10
|
+
export declare function withLock<T>(path: string, work: () => Promise<T>, timeoutMs?: number, signal?: AbortSignal): Promise<T>;
|
package/dist/storage.js
ADDED
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
import { constants } from "node:fs";
|
|
2
|
+
import { lstat, mkdir, open, readdir, rename, unlink } from "node:fs/promises";
|
|
3
|
+
import { randomUUID } from "node:crypto";
|
|
4
|
+
import { dirname, join, resolve, parse } from "node:path";
|
|
5
|
+
import { homedir } from "node:os";
|
|
6
|
+
import { setTimeout as delay } from "node:timers/promises";
|
|
7
|
+
import { CliError, errorCode, record, throwIfInterrupted } from "./errors.js";
|
|
8
|
+
export function configDirectory(env = process.env) {
|
|
9
|
+
const base = env.XDG_CONFIG_HOME ?? join(homedir(), ".config");
|
|
10
|
+
if (!base.startsWith("/"))
|
|
11
|
+
throw new CliError("XDG_CONFIG_HOME must be an absolute path.");
|
|
12
|
+
return join(base, "zo", "cli");
|
|
13
|
+
}
|
|
14
|
+
export async function privateDirectory(path) {
|
|
15
|
+
const absolute = resolve(path);
|
|
16
|
+
let current = parse(absolute).root;
|
|
17
|
+
for (const part of absolute.slice(current.length).split("/")) {
|
|
18
|
+
current = join(current, part);
|
|
19
|
+
try {
|
|
20
|
+
await mkdir(current, { mode: 0o700 });
|
|
21
|
+
await syncDirectory(dirname(current));
|
|
22
|
+
}
|
|
23
|
+
catch (error) {
|
|
24
|
+
if (errorCode(error) !== "EEXIST")
|
|
25
|
+
throw error;
|
|
26
|
+
}
|
|
27
|
+
const stat = await lstat(current);
|
|
28
|
+
if (stat.uid !== 0 && stat.uid !== process.getuid?.())
|
|
29
|
+
throw new CliError("Credential path parents must be owned by you or root.");
|
|
30
|
+
if (!stat.isDirectory() || stat.isSymbolicLink())
|
|
31
|
+
throw new CliError("Credential paths must not contain symlinks.");
|
|
32
|
+
if ((stat.mode & 0o022) !== 0 && (stat.mode & 0o1000) === 0)
|
|
33
|
+
throw new CliError("Credential path parents must not be writable by other users.");
|
|
34
|
+
}
|
|
35
|
+
const stat = await lstat(absolute);
|
|
36
|
+
if (stat.uid !== process.getuid?.() || (stat.mode & 0o077) !== 0)
|
|
37
|
+
throw new CliError("Credential directories must be owned by you with mode 0700.");
|
|
38
|
+
}
|
|
39
|
+
async function checkedFile(path) {
|
|
40
|
+
const file = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW);
|
|
41
|
+
const stat = await file.stat();
|
|
42
|
+
if (!stat.isFile() ||
|
|
43
|
+
stat.uid !== process.getuid?.() ||
|
|
44
|
+
(stat.mode & 0o077) !== 0 ||
|
|
45
|
+
stat.nlink > 1) {
|
|
46
|
+
await file.close();
|
|
47
|
+
throw new CliError("Credential files must be private, regular files owned by you.");
|
|
48
|
+
}
|
|
49
|
+
return file;
|
|
50
|
+
}
|
|
51
|
+
export async function readPrivate(path) {
|
|
52
|
+
let file;
|
|
53
|
+
try {
|
|
54
|
+
file = await checkedFile(path);
|
|
55
|
+
}
|
|
56
|
+
catch (error) {
|
|
57
|
+
if (errorCode(error) === "ENOENT")
|
|
58
|
+
return undefined;
|
|
59
|
+
throw error;
|
|
60
|
+
}
|
|
61
|
+
try {
|
|
62
|
+
if ((await file.stat()).size > 1_048_576)
|
|
63
|
+
throw new CliError("Local configuration is too large.", 2, "invalid_local_data");
|
|
64
|
+
return JSON.parse(await file.readFile("utf8"));
|
|
65
|
+
}
|
|
66
|
+
catch (error) {
|
|
67
|
+
if (error instanceof SyntaxError)
|
|
68
|
+
throw new CliError("Local configuration is malformed.", 2, "invalid_local_data");
|
|
69
|
+
throw error;
|
|
70
|
+
}
|
|
71
|
+
finally {
|
|
72
|
+
await file.close();
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
async function checkExistingFile(path) {
|
|
76
|
+
try {
|
|
77
|
+
const file = await checkedFile(path);
|
|
78
|
+
await file.close();
|
|
79
|
+
return true;
|
|
80
|
+
}
|
|
81
|
+
catch (error) {
|
|
82
|
+
if (errorCode(error) === "ENOENT")
|
|
83
|
+
return false;
|
|
84
|
+
throw error;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
export class PrivateWriteDurabilityError extends CliError {
|
|
88
|
+
constructor() {
|
|
89
|
+
super("The local file was replaced, but its directory could not be synced. Saved credentials were retained; retry before removing local files.", 5, "local_durability_failed");
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
export async function writePrivate(path, value) {
|
|
93
|
+
await privateDirectory(dirname(path));
|
|
94
|
+
await checkExistingFile(path);
|
|
95
|
+
const temporary = join(dirname(path), `.${randomUUID()}.tmp`);
|
|
96
|
+
const file = await open(temporary, constants.O_WRONLY |
|
|
97
|
+
constants.O_CREAT |
|
|
98
|
+
constants.O_EXCL |
|
|
99
|
+
constants.O_NOFOLLOW, 0o600);
|
|
100
|
+
try {
|
|
101
|
+
await file.writeFile(JSON.stringify(value));
|
|
102
|
+
await file.sync();
|
|
103
|
+
await file.close();
|
|
104
|
+
await rename(temporary, path);
|
|
105
|
+
try {
|
|
106
|
+
await syncDirectory(dirname(path));
|
|
107
|
+
}
|
|
108
|
+
catch {
|
|
109
|
+
// The replacement is already visible. Callers must retain its tokens
|
|
110
|
+
// even though a crash could still lose the directory update.
|
|
111
|
+
throw new PrivateWriteDurabilityError();
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
finally {
|
|
115
|
+
await file.close();
|
|
116
|
+
await unlink(temporary).catch((error) => {
|
|
117
|
+
if (errorCode(error) !== "ENOENT")
|
|
118
|
+
throw error;
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
async function syncDirectory(path) {
|
|
123
|
+
const directory = await open(path, constants.O_RDONLY);
|
|
124
|
+
try {
|
|
125
|
+
await directory.sync();
|
|
126
|
+
}
|
|
127
|
+
finally {
|
|
128
|
+
await directory.close();
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
export async function removePrivate(path) {
|
|
132
|
+
if (!(await checkExistingFile(path)))
|
|
133
|
+
return;
|
|
134
|
+
await unlink(path);
|
|
135
|
+
await syncDirectory(dirname(path));
|
|
136
|
+
}
|
|
137
|
+
function alive(pid) {
|
|
138
|
+
try {
|
|
139
|
+
process.kill(pid, 0);
|
|
140
|
+
return true;
|
|
141
|
+
}
|
|
142
|
+
catch (error) {
|
|
143
|
+
return errorCode(error) !== "ESRCH";
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
// Bakery tickets avoid replacing a shared stale lock: each process removes only
|
|
147
|
+
// its own UUID path. A live owner is never evicted by elapsed time.
|
|
148
|
+
export async function withLock(path, work, timeoutMs = 30_000, signal) {
|
|
149
|
+
throwIfInterrupted(signal);
|
|
150
|
+
await privateDirectory(path);
|
|
151
|
+
const name = `${process.pid}-${randomUUID()}.json`;
|
|
152
|
+
const own = join(path, name);
|
|
153
|
+
const start = performance.now();
|
|
154
|
+
const claims = async () => {
|
|
155
|
+
const found = [];
|
|
156
|
+
for (const entry of await readdir(path)) {
|
|
157
|
+
if (!entry.endsWith(".json"))
|
|
158
|
+
continue;
|
|
159
|
+
const claim = await readPrivate(join(path, entry));
|
|
160
|
+
if (claim === undefined)
|
|
161
|
+
continue;
|
|
162
|
+
if (!record(claim) ||
|
|
163
|
+
!Number.isSafeInteger(claim.pid) ||
|
|
164
|
+
Number(claim.pid) <= 0 ||
|
|
165
|
+
!Number.isSafeInteger(claim.ticket) ||
|
|
166
|
+
Number(claim.ticket) < 0)
|
|
167
|
+
throw new CliError("Malformed credential lock.");
|
|
168
|
+
if (alive(Number(claim.pid)))
|
|
169
|
+
found.push([
|
|
170
|
+
entry,
|
|
171
|
+
{ pid: Number(claim.pid), ticket: Number(claim.ticket) },
|
|
172
|
+
]);
|
|
173
|
+
else
|
|
174
|
+
await removePrivate(join(path, entry)).catch((error) => {
|
|
175
|
+
if (errorCode(error) !== "ENOENT")
|
|
176
|
+
throw error;
|
|
177
|
+
});
|
|
178
|
+
}
|
|
179
|
+
return found;
|
|
180
|
+
};
|
|
181
|
+
await writePrivate(own, { pid: process.pid, ticket: 0 });
|
|
182
|
+
try {
|
|
183
|
+
throwIfInterrupted(signal);
|
|
184
|
+
const ticket = Math.max(0, ...(await claims()).map(([, claim]) => claim.ticket)) + 1;
|
|
185
|
+
await writePrivate(own, { pid: process.pid, ticket });
|
|
186
|
+
for (;;) {
|
|
187
|
+
throwIfInterrupted(signal);
|
|
188
|
+
const blocked = (await claims()).some(([entry, claim]) => entry !== name &&
|
|
189
|
+
(claim.ticket === 0 ||
|
|
190
|
+
claim.ticket < ticket ||
|
|
191
|
+
(claim.ticket === ticket && entry < name)));
|
|
192
|
+
throwIfInterrupted(signal);
|
|
193
|
+
if (!blocked)
|
|
194
|
+
return await work();
|
|
195
|
+
if (performance.now() - start >= timeoutMs)
|
|
196
|
+
throw new CliError("Timed out waiting for another Zo process. Try again after it finishes.", 3, "credential_locked");
|
|
197
|
+
try {
|
|
198
|
+
await delay(20, undefined, { signal });
|
|
199
|
+
}
|
|
200
|
+
catch (error) {
|
|
201
|
+
throwIfInterrupted(signal);
|
|
202
|
+
throw error;
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
finally {
|
|
207
|
+
await removePrivate(own);
|
|
208
|
+
}
|
|
209
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,23 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zocomputer/cli",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"description": "
|
|
6
|
-
|
|
3
|
+
"version": "0.1.1",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"description": "Public Zo command line client",
|
|
6
|
+
"bin": {
|
|
7
|
+
"zo": "./dist/main.js"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"dist",
|
|
11
|
+
"README.md"
|
|
12
|
+
],
|
|
13
|
+
"engines": {
|
|
14
|
+
"node": "^22.23.4 || ^24.22.1 || ^26.13.0"
|
|
15
|
+
},
|
|
16
|
+
"dependencies": {
|
|
17
|
+
"@zocomputer/system-api": "0.1.1"
|
|
18
|
+
},
|
|
19
|
+
"publishConfig": {
|
|
20
|
+
"access": "public",
|
|
21
|
+
"registry": "https://registry.npmjs.org/"
|
|
22
|
+
}
|
|
23
|
+
}
|