disk 0.8.12 → 0.8.13

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 CHANGED
@@ -82,6 +82,80 @@ await archil.createApiKey({ name: "ci-bot", description: "GitHub Actions" });
82
82
  await archil.deleteApiKey("key-abc123");
83
83
  ```
84
84
 
85
+ ### Reading and writing objects
86
+
87
+ A `Disk` doubles as an S3-compatible bucket: read, write, delete, and list its
88
+ files by key without mounting it. These methods talk to Archil's S3 endpoint
89
+ using your same API key (no separate S3 credentials or SigV4 signing on your
90
+ part).
91
+
92
+ ```ts
93
+ const d = await archil.getDisk("dsk-abc123");
94
+
95
+ // Write — accepts a string, Uint8Array/Buffer, or ArrayBuffer. Returns the etag.
96
+ const { etag } = await d.putObject("reports/2026-01/data.json", JSON.stringify(report), "application/json");
97
+
98
+ // Read — returns the bytes (a Uint8Array).
99
+ const bytes = await d.getObject("reports/2026-01/data.json");
100
+ const text = new TextDecoder().decode(bytes);
101
+
102
+ // Metadata / existence without downloading the body
103
+ const meta = await d.headObject("reports/2026-01/data.json"); // null if absent
104
+ if (await d.objectExists("reports/2026-01/data.json")) { /* … */ }
105
+
106
+ // Delete (idempotent — deleting a missing key succeeds)
107
+ await d.deleteObject("reports/2026-01/data.json");
108
+ ```
109
+
110
+ `listObjects` auto-paginates by default, returning every matching key. The first
111
+ argument is a key prefix; a non-recursive listing (the default) returns the
112
+ immediate level as `objects` plus subdirectory `commonPrefixes`:
113
+
114
+ ```ts
115
+ const { objects, commonPrefixes } = await d.listObjects("reports/"); // one level
116
+ const all = await d.listObjects("reports/", { recursive: true }); // whole subtree
117
+ const first100 = await d.listObjects("reports/", { limit: 100 }); // cap the total
118
+
119
+ // Stream pages instead of buffering everything (large listings):
120
+ for await (const page of d.listObjectsPages("reports/")) {
121
+ for (const obj of page.objects) console.log(obj.key, obj.size, obj.lastModified);
122
+ }
123
+
124
+ // Or drive pagination yourself:
125
+ const page = await d.listObjects("reports/", { singlePage: true });
126
+ if (page.isTruncated) {
127
+ const next = await d.listObjects("reports/", { singlePage: true, continuationToken: page.nextContinuationToken });
128
+ }
129
+ ```
130
+
131
+ Failures throw `ArchilS3Error` with `status` (HTTP status), `code` (the S3 error
132
+ code, e.g. `"NoSuchKey"`), `requestId`, and the raw body on `raw`. `getObject`
133
+ on a missing key throws a 404 — use `headObject`/`objectExists` to probe without
134
+ catching. All SDK errors extend `ArchilError`, so `catch (e) { if (e instanceof
135
+ ArchilError) … }` handles control-plane and S3 failures uniformly.
136
+
137
+ The S3 endpoint is derived from your region automatically. To target a custom
138
+ environment, set `s3BaseUrl` on the `Archil` constructor (or the
139
+ `ARCHIL_S3_BASE_URL` env var).
140
+
141
+ ### Sharing files
142
+
143
+ `share` mints a signed, time-limited link to a single file. Anyone with the link
144
+ can download that file — no API key, no mounting. The link carries a
145
+ cryptographically signed token (disk + key + expiry); when it expires it stops
146
+ working.
147
+
148
+ ```ts
149
+ const d = await archil.getDisk("dsk-abc123");
150
+
151
+ // Default lifetime is 24 hours.
152
+ const { url, expiresIn } = await d.share("reports/2026-01/summary.pdf");
153
+ console.log(url); // https://control.…/api/shared/<token>
154
+
155
+ // Set the lifetime in seconds (any positive integer, up to 604800 = 7 days):
156
+ const weekLink = await d.share("reports/2026-01/summary.pdf", { expiresIn: 604800 });
157
+ ```
158
+
85
159
  ### Named imports
86
160
 
87
161
  If you prefer named imports over the namespace style, they work the same way:
package/dist/cli.js CHANGED
@@ -1,23 +1,83 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  // bin/cli.ts
4
- import { createRequire as createRequire2 } from "module";
4
+ import { createRequire } from "module";
5
5
  import { Command, Option } from "commander";
6
6
 
7
7
  // src/client.ts
8
- import createClient from "openapi-fetch";
8
+ import createClientDefault from "openapi-fetch";
9
+
10
+ // src/s3xml.ts
11
+ import { XMLParser } from "fast-xml-parser";
12
+ var parser = new XMLParser({
13
+ isArray: (name) => name === "Contents" || name === "CommonPrefixes",
14
+ parseTagValue: false,
15
+ trimValues: true,
16
+ ignoreAttributes: true
17
+ });
18
+ function parseXml(xml) {
19
+ if (!xml.trim()) return {};
20
+ return parser.parse(xml);
21
+ }
9
22
 
10
23
  // src/errors.ts
11
- var ArchilApiError = class extends Error {
24
+ var ArchilError = class extends Error {
25
+ /** HTTP status code associated with the failure. */
12
26
  status;
27
+ /** Machine-readable error code (e.g. an S3 code like "NoSuchKey"), if known. */
13
28
  code;
14
29
  constructor(message, status, code) {
15
30
  super(message);
16
- this.name = "ArchilApiError";
31
+ this.name = "ArchilError";
17
32
  this.status = status;
18
33
  this.code = code;
19
34
  }
20
35
  };
36
+ var ArchilApiError = class extends ArchilError {
37
+ constructor(message, status, code) {
38
+ super(message, status, code);
39
+ this.name = "ArchilApiError";
40
+ }
41
+ };
42
+ var ArchilS3Error = class extends ArchilError {
43
+ /** S3 request id, if the gateway returned one. */
44
+ requestId;
45
+ /** Raw response body (the XML document), for debugging. */
46
+ raw;
47
+ constructor(opts) {
48
+ const detail = opts.message ?? opts.statusText ?? "";
49
+ const codePart = opts.code ? ` ${opts.code}` : "";
50
+ super(
51
+ `S3 ${opts.operation} failed: ${opts.statusCode}${codePart}${detail ? ` \u2014 ${detail}` : ""}`,
52
+ opts.statusCode,
53
+ opts.code
54
+ );
55
+ this.name = "ArchilS3Error";
56
+ this.requestId = opts.requestId;
57
+ this.raw = opts.raw;
58
+ }
59
+ };
60
+ function tagString(obj, tag) {
61
+ const value = obj[tag];
62
+ return value === void 0 || value === null ? void 0 : String(value);
63
+ }
64
+ function parseS3Error(operation, statusCode, statusText, body) {
65
+ let err = {};
66
+ try {
67
+ err = parseXml(body).Error ?? {};
68
+ } catch {
69
+ err = {};
70
+ }
71
+ return new ArchilS3Error({
72
+ operation,
73
+ statusCode,
74
+ statusText,
75
+ code: tagString(err, "Code"),
76
+ message: tagString(err, "Message"),
77
+ requestId: tagString(err, "RequestId"),
78
+ raw: body
79
+ });
80
+ }
21
81
 
22
82
  // src/regions.ts
23
83
  var REGION_URLS = {
@@ -35,14 +95,32 @@ function resolveBaseUrl(region) {
35
95
  }
36
96
  return url;
37
97
  }
98
+ function deriveS3BaseUrl(controlBaseUrl) {
99
+ try {
100
+ const u = new URL(controlBaseUrl);
101
+ u.hostname = u.hostname.replace(/^control\./, "s3.");
102
+ return u.toString().replace(/\/$/, "");
103
+ } catch {
104
+ return void 0;
105
+ }
106
+ }
107
+
108
+ // src/version.ts
109
+ var VERSION = true ? "0.8.13" : "0.0.0-dev";
110
+ var USER_AGENT = `archil-js/${VERSION}`;
38
111
 
39
112
  // src/client.ts
113
+ var createClient = typeof createClientDefault === "function" ? createClientDefault : createClientDefault.default;
40
114
  function createApiClient(opts) {
41
115
  const baseUrl = opts.baseUrl ?? resolveBaseUrl(opts.region);
42
116
  return createClient({
43
117
  baseUrl,
44
118
  headers: {
45
- Authorization: `key-${opts.apiKey.replace(/^key-/, "")}`
119
+ Authorization: `key-${opts.apiKey.replace(/^key-/, "")}`,
120
+ // Identifies the JS SDK (and its version) to the control plane. Honored
121
+ // by Node's fetch; browsers treat User-Agent as a forbidden header and
122
+ // drop it, which is fine — the SDK's primary use is server-side.
123
+ "User-Agent": USER_AGENT
46
124
  }
47
125
  });
48
126
  }
@@ -81,8 +159,6 @@ async function unwrapEmpty(promise) {
81
159
  }
82
160
 
83
161
  // src/disk.ts
84
- import { createRequire } from "module";
85
- import { pathToFileURL } from "url";
86
162
  var Disk = class {
87
163
  id;
88
164
  name;
@@ -104,8 +180,10 @@ var Disk = class {
104
180
  _client;
105
181
  /** @internal */
106
182
  _archilRegion;
183
+ /** Base URL for the S3-compatible API (port 9000 ingress). Empty if unset. */
184
+ _s3BaseUrl;
107
185
  /** @internal */
108
- constructor(data, client, archilRegion) {
186
+ constructor(data, client, archilRegion, s3BaseUrl) {
109
187
  this.id = data.id;
110
188
  this.name = data.name;
111
189
  this.organization = data.organization;
@@ -124,6 +202,7 @@ var Disk = class {
124
202
  this.allowedIps = data.allowedIps;
125
203
  this._client = client;
126
204
  this._archilRegion = archilRegion;
205
+ this._s3BaseUrl = s3BaseUrl ?? "";
127
206
  }
128
207
  toJSON() {
129
208
  return {
@@ -248,6 +327,219 @@ var Disk = class {
248
327
  })
249
328
  );
250
329
  }
330
+ /**
331
+ * Create a signed, time-limited URL that lets anyone download a single file
332
+ * from this disk without authentication. The returned URL embeds a
333
+ * cryptographically signed token carrying the disk, the file's key, and an
334
+ * expiry — share it directly; no API key is needed to redeem it.
335
+ *
336
+ * @param key Path to the file on the disk (e.g. "reports/2026-01/data.pdf").
337
+ * @param opts `expiresIn` sets the URL lifetime in seconds (any positive
338
+ * integer, max 604800 = 7 days). Defaults to 24h.
339
+ */
340
+ async share(key, opts = {}) {
341
+ const body = { key };
342
+ if (opts.expiresIn !== void 0) body.expiresIn = opts.expiresIn;
343
+ const call = this._client.POST;
344
+ return unwrap(call(`/api/disks/${this.id}/share`, { body }));
345
+ }
346
+ /**
347
+ * Read an object from the disk via the S3-compatible GetObject API and return
348
+ * its full contents as bytes. Throws `ArchilS3Error` if the object does not
349
+ * exist (status 404, code "NoSuchKey") or the request is rejected; use
350
+ * `headObject`/`objectExists` to check existence without throwing.
351
+ *
352
+ * @param key Path on the disk (e.g. "reports/2026-01/data.json")
353
+ */
354
+ async getObject(key) {
355
+ const resp = await this._s3Request("GET", key);
356
+ if (!resp.ok) {
357
+ throw parseS3Error("GetObject", resp.status, resp.statusText, decodeText(resp.body));
358
+ }
359
+ return resp.body;
360
+ }
361
+ /**
362
+ * Fetch an object's metadata (size, etag, content type, last-modified) without
363
+ * downloading its contents, via the S3-compatible HeadObject API. Returns
364
+ * `null` if the object does not exist.
365
+ *
366
+ * @param key Path on the disk (e.g. "reports/2026-01/data.json")
367
+ */
368
+ async headObject(key) {
369
+ const resp = await this._s3Request("HEAD", key);
370
+ if (resp.status === 404) return null;
371
+ if (!resp.ok) {
372
+ throw parseS3Error("HeadObject", resp.status, resp.statusText, decodeText(resp.body));
373
+ }
374
+ const lastModified = resp.headers.get("last-modified");
375
+ return {
376
+ size: Number(resp.headers.get("content-length") ?? 0),
377
+ etag: resp.headers.get("etag") ?? void 0,
378
+ contentType: resp.headers.get("content-type") ?? void 0,
379
+ lastModified: lastModified ? new Date(lastModified) : void 0
380
+ };
381
+ }
382
+ /** Whether an object exists on the disk (a HeadObject that maps 404 → false). */
383
+ async objectExists(key) {
384
+ return await this.headObject(key) !== null;
385
+ }
386
+ /**
387
+ * Write an object to the disk using the S3-compatible PutObject API. Faster
388
+ * than exec for large files — no container overhead, no command-length limits.
389
+ * Returns the entity tag the server assigned.
390
+ *
391
+ * @param key Path on the disk (e.g. "reports/2026-01/data.json")
392
+ * @param body Contents as a string, Uint8Array/Buffer, or ArrayBuffer
393
+ * @param contentType MIME type (default: "application/octet-stream")
394
+ */
395
+ async putObject(key, body, contentType = "application/octet-stream") {
396
+ const resp = await this._s3Request("PUT", key, { body, contentType });
397
+ if (!resp.ok) {
398
+ throw parseS3Error("PutObject", resp.status, resp.statusText, decodeText(resp.body));
399
+ }
400
+ return { etag: resp.headers.get("etag") ?? void 0 };
401
+ }
402
+ /**
403
+ * Delete an object from the disk via the S3-compatible DeleteObject API.
404
+ * Idempotent: deleting a key that doesn't exist resolves successfully, per
405
+ * S3 semantics.
406
+ *
407
+ * @param key Path on the disk (e.g. "project/dist/server.cjs")
408
+ */
409
+ async deleteObject(key) {
410
+ const resp = await this._s3Request("DELETE", key);
411
+ if (!resp.ok && resp.status !== 404) {
412
+ throw parseS3Error("DeleteObject", resp.status, resp.statusText, decodeText(resp.body));
413
+ }
414
+ }
415
+ /**
416
+ * List objects on the disk via the S3-compatible ListObjectsV2 API. By
417
+ * default this follows continuation tokens until the listing is exhausted and
418
+ * returns every matching key. Use `limit` to cap the total, `singlePage` for a
419
+ * single request, or {@link listObjectsPages} to stream pages without loading
420
+ * everything into memory.
421
+ *
422
+ * @param prefix Only return keys beginning with this prefix (omit for all).
423
+ * @param opts Listing and pagination options.
424
+ */
425
+ async listObjects(prefix, opts = {}) {
426
+ if (opts.singlePage) {
427
+ return this._listObjectsPage(prefix, opts);
428
+ }
429
+ const objects = [];
430
+ const commonPrefixes = [];
431
+ const seenPrefixes = /* @__PURE__ */ new Set();
432
+ let echoedPrefix;
433
+ let truncated = false;
434
+ outer: for await (const page of this.listObjectsPages(prefix, opts)) {
435
+ echoedPrefix = page.prefix;
436
+ for (const cp of page.commonPrefixes) {
437
+ if (!seenPrefixes.has(cp)) {
438
+ seenPrefixes.add(cp);
439
+ commonPrefixes.push(cp);
440
+ }
441
+ }
442
+ for (const obj of page.objects) {
443
+ if (opts.limit !== void 0 && objects.length >= opts.limit) {
444
+ truncated = true;
445
+ break outer;
446
+ }
447
+ objects.push(obj);
448
+ }
449
+ }
450
+ return { objects, commonPrefixes, isTruncated: truncated, keyCount: objects.length, prefix: echoedPrefix };
451
+ }
452
+ /**
453
+ * Yield ListObjectsV2 pages lazily, following continuation tokens. A
454
+ * memory-friendly way to process a large listing without materializing it:
455
+ *
456
+ * ```ts
457
+ * for await (const page of disk.listObjectsPages("logs/")) {
458
+ * for (const obj of page.objects) process(obj);
459
+ * }
460
+ * ```
461
+ *
462
+ * @param prefix Only return keys beginning with this prefix (omit for all).
463
+ * @param opts Listing options (`limit` / `singlePage` are ignored here —
464
+ * control your own loop).
465
+ */
466
+ async *listObjectsPages(prefix, opts = {}) {
467
+ const seenTokens = /* @__PURE__ */ new Set();
468
+ let continuationToken = opts.continuationToken;
469
+ for (; ; ) {
470
+ const page = await this._listObjectsPage(prefix, { ...opts, continuationToken });
471
+ yield page;
472
+ const next = page.isTruncated ? page.nextContinuationToken : void 0;
473
+ if (!next || seenTokens.has(next)) break;
474
+ seenTokens.add(next);
475
+ continuationToken = next;
476
+ }
477
+ }
478
+ /** Fetch a single ListObjectsV2 page. @internal */
479
+ async _listObjectsPage(prefix, opts) {
480
+ const query = { "list-type": 2 };
481
+ if (prefix !== void 0) query.prefix = prefix;
482
+ if (!opts.recursive) query.delimiter = "/";
483
+ if (opts.continuationToken !== void 0) query["continuation-token"] = opts.continuationToken;
484
+ if (opts.startAfter !== void 0) query["start-after"] = opts.startAfter;
485
+ const resp = await this._s3Request("GET", "", { query });
486
+ if (!resp.ok) {
487
+ throw parseS3Error("ListObjectsV2", resp.status, resp.statusText, decodeText(resp.body));
488
+ }
489
+ return parseListObjectsResult(decodeText(resp.body));
490
+ }
491
+ /**
492
+ * Send a single request to the disk's S3-compatible endpoint. This reuses the
493
+ * control-plane client purely for its credential and transport — the same
494
+ * `Authorization` header is sent and verified by the same code server-side —
495
+ * pointed at the S3 host. Returns the response status and fully-buffered body
496
+ * so callers can inspect both regardless of the verb used.
497
+ *
498
+ * The S3 routes (`/{diskId}/{key}` for objects, `/{diskId}` for the bucket)
499
+ * are not part of the typed control-plane API, so the path and per-request
500
+ * options are passed untyped. An empty `key` targets the bucket itself (used
501
+ * by listObjects).
502
+ *
503
+ * @internal
504
+ */
505
+ async _s3Request(method, key, opts = {}) {
506
+ if (!this._s3BaseUrl) {
507
+ throw new Error(
508
+ "S3 base URL not configured. Pass s3BaseUrl to new Archil({...}) or set ARCHIL_S3_BASE_URL."
509
+ );
510
+ }
511
+ const call = this._client[method];
512
+ const trimmedKey = key.replace(/^\//, "");
513
+ const encodedKey = trimmedKey.split("/").map(encodeURIComponent).join("/");
514
+ const path = encodedKey ? `/${this.id}/${encodedKey}` : `/${this.id}`;
515
+ const { error, response } = await call(path, {
516
+ baseUrl: this._s3BaseUrl,
517
+ parseAs: "stream",
518
+ ...opts.query ? { params: { query: opts.query } } : {},
519
+ // fetch accepts string / Uint8Array / ArrayBuffer bodies directly; pass it
520
+ // through unchanged (no Node Buffer in the data path).
521
+ ...opts.body !== void 0 ? { body: opts.body, bodySerializer: (b) => b } : {},
522
+ ...opts.contentType ? { headers: { "Content-Type": opts.contentType } } : {}
523
+ });
524
+ if (!response.ok) {
525
+ const message = typeof error === "string" ? error : error ? JSON.stringify(error) : "";
526
+ return {
527
+ ok: false,
528
+ status: response.status,
529
+ statusText: response.statusText,
530
+ headers: response.headers,
531
+ body: new TextEncoder().encode(message)
532
+ };
533
+ }
534
+ const bytes = method === "GET" ? new Uint8Array(await response.arrayBuffer()) : new Uint8Array(0);
535
+ return {
536
+ ok: true,
537
+ status: response.status,
538
+ statusText: response.statusText,
539
+ headers: response.headers,
540
+ body: bytes
541
+ };
542
+ }
251
543
  /**
252
544
  * Connect to this disk's data plane via the native ArchilClient.
253
545
  *
@@ -256,13 +548,15 @@ var Disk = class {
256
548
  async mount(opts) {
257
549
  let ArchilClient;
258
550
  try {
259
- const base = typeof import.meta !== "undefined" && import.meta.url ? import.meta.url : pathToFileURL(__filename).href;
260
- const nativeRequire = createRequire(base);
261
- const native = nativeRequire("@archildata/native");
262
- ArchilClient = native.ArchilClient;
551
+ const nativeSpecifier = "@archildata/native";
552
+ const native = await import(nativeSpecifier);
553
+ ArchilClient = native.ArchilClient ?? native.default?.ArchilClient;
554
+ if (!ArchilClient) {
555
+ throw new Error("@archildata/native did not export ArchilClient");
556
+ }
263
557
  } catch {
264
558
  throw new Error(
265
- "Native client not available. Install @archildata/native (platform-specific binary) or use ArchilClient.connect() directly."
559
+ "Native client not available. Install @archildata/native (platform-specific binary) or use ArchilClient.connect() directly. mount() is not supported in browsers."
266
560
  );
267
561
  }
268
562
  return ArchilClient.connect({
@@ -275,6 +569,34 @@ var Disk = class {
275
569
  });
276
570
  }
277
571
  };
572
+ function optionalString(value) {
573
+ return value === void 0 || value === null ? void 0 : String(value);
574
+ }
575
+ function optionalDate(value) {
576
+ return value === void 0 || value === null ? void 0 : new Date(String(value));
577
+ }
578
+ function decodeText(bytes) {
579
+ return new TextDecoder().decode(bytes);
580
+ }
581
+ function parseListObjectsResult(xml) {
582
+ const root = parseXml(xml).ListBucketResult ?? {};
583
+ const contents = root.Contents ?? [];
584
+ const objects = contents.map((c) => ({
585
+ key: String(c.Key ?? ""),
586
+ size: Number(c.Size ?? 0),
587
+ etag: optionalString(c.ETag),
588
+ lastModified: optionalDate(c.LastModified)
589
+ }));
590
+ const commonPrefixes = (root.CommonPrefixes ?? []).map((cp) => optionalString(cp.Prefix)).filter((p) => p !== void 0);
591
+ return {
592
+ objects,
593
+ commonPrefixes,
594
+ isTruncated: root.IsTruncated === "true" || root.IsTruncated === true,
595
+ nextContinuationToken: optionalString(root.NextContinuationToken),
596
+ keyCount: root.KeyCount !== void 0 ? Number(root.KeyCount) : objects.length,
597
+ prefix: optionalString(root.Prefix)
598
+ };
599
+ }
278
600
 
279
601
  // src/disks.ts
280
602
  var Disks = class {
@@ -283,9 +605,12 @@ var Disks = class {
283
605
  /** @internal */
284
606
  _region;
285
607
  /** @internal */
286
- constructor(client, region) {
608
+ _s3BaseUrl;
609
+ /** @internal */
610
+ constructor(client, region, s3BaseUrl) {
287
611
  this._client = client;
288
612
  this._region = region;
613
+ this._s3BaseUrl = s3BaseUrl;
289
614
  }
290
615
  async list(opts) {
291
616
  const data = await unwrap(
@@ -294,7 +619,7 @@ var Disks = class {
294
619
  })
295
620
  );
296
621
  return data.map(
297
- (d) => new Disk(d, this._client, this._region)
622
+ (d) => new Disk(d, this._client, this._region, this._s3BaseUrl)
298
623
  );
299
624
  }
300
625
  async get(id) {
@@ -303,7 +628,7 @@ var Disks = class {
303
628
  params: { path: { id } }
304
629
  })
305
630
  );
306
- return new Disk(data, this._client, this._region);
631
+ return new Disk(data, this._client, this._region, this._s3BaseUrl);
307
632
  }
308
633
  /**
309
634
  * Create a new disk with an auto-generated mount token.
@@ -363,6 +688,9 @@ var Tokens = class {
363
688
  };
364
689
 
365
690
  // src/archil.ts
691
+ function envVar(name) {
692
+ return typeof process !== "undefined" && process.env ? process.env[name] : void 0;
693
+ }
366
694
  function isExecMountSpec(m) {
367
695
  return typeof m === "object" && m !== null && "disk" in m;
368
696
  }
@@ -375,21 +703,23 @@ var Archil = class {
375
703
  /** @internal */
376
704
  _client;
377
705
  constructor(opts = {}) {
378
- const apiKey = opts.apiKey ?? process.env.ARCHIL_API_KEY;
379
- const region = opts.region ?? process.env.ARCHIL_REGION;
706
+ const apiKey = opts.apiKey ?? envVar("ARCHIL_API_KEY");
707
+ const region = opts.region ?? envVar("ARCHIL_REGION");
380
708
  if (!apiKey) {
381
709
  throw new Error("Missing API key: pass apiKey in options or set ARCHIL_API_KEY environment variable");
382
710
  }
383
711
  if (!region) {
384
712
  throw new Error("Missing region: pass region in options or set ARCHIL_REGION environment variable");
385
713
  }
714
+ const controlBaseUrl = opts.baseUrl ?? resolveBaseUrl(region);
386
715
  const client = createApiClient({
387
716
  apiKey,
388
717
  region,
389
- baseUrl: opts.baseUrl
718
+ baseUrl: controlBaseUrl
390
719
  });
720
+ const s3BaseUrl = opts.s3BaseUrl ?? envVar("ARCHIL_S3_BASE_URL") ?? deriveS3BaseUrl(controlBaseUrl);
391
721
  this._client = client;
392
- this.disks = new Disks(client, region);
722
+ this.disks = new Disks(client, region, s3BaseUrl);
393
723
  this.tokens = new Tokens(client);
394
724
  }
395
725
  /**
@@ -452,7 +782,7 @@ function deleteApiKey(id) {
452
782
  }
453
783
 
454
784
  // bin/cli.ts
455
- var pkg = createRequire2(import.meta.url)("../package.json");
785
+ var pkg = createRequire(import.meta.url)("../package.json");
456
786
  var program = new Command();
457
787
  program.name("disk").description("Manage Archil disks from the command line").version(pkg.version).addOption(new Option("-k, --api-key <key>", "Archil API key").env("ARCHIL_API_KEY")).addOption(new Option("-r, --region <region>", "Archil region").env("ARCHIL_REGION")).addOption(new Option("--base-url <url>", "Override control-plane base URL")).hook("preAction", () => {
458
788
  const opts = program.opts();
@@ -632,6 +962,44 @@ program.command("exec <id|name> <command...>").description("Run a command in a c
632
962
  fail(err);
633
963
  }
634
964
  });
965
+ program.command("grep <pattern> <target>").description(
966
+ "Constant-time parallel server-side grep across a disk. <target> is <id|name>[/<path>] \u2014 the path after the first '/' is the directory to search (defaults to the disk root). Accepts a disk id or name."
967
+ ).option("--no-recursive", "Search only the given directory, not its subdirectories (recursive is the default)").option("--max-duration <seconds>", "Wall-clock deadline for the whole search", (v) => parseInt(v, 10)).option("--concurrency <n>", "Max parallel grep workers (clamped to fleet capacity)", (v) => parseInt(v, 10)).option("--max-results <n>", "Stop once this many matches are collected", (v) => parseInt(v, 10)).option("-o, --output <format>", "Output format: text | json", "text").action(
968
+ async (pattern, target, opts) => {
969
+ try {
970
+ const slash = target.indexOf("/");
971
+ const idOrName = slash === -1 ? target : target.slice(0, slash);
972
+ const directory = slash === -1 ? "" : target.slice(slash + 1);
973
+ const d = await resolveDisk(idOrName);
974
+ const res = await d.grep({
975
+ directory,
976
+ pattern,
977
+ recursive: opts.recursive !== false,
978
+ maxDurationSeconds: opts.maxDuration,
979
+ concurrency: opts.concurrency,
980
+ maxResults: opts.maxResults
981
+ });
982
+ if (opts.output === "json") {
983
+ console.log(JSON.stringify(res, null, 2));
984
+ } else {
985
+ for (const m of res.matches) {
986
+ console.log(`${m.file}:${m.line}:${m.text}`);
987
+ }
988
+ console.error(
989
+ `${res.matches.length} match${res.matches.length === 1 ? "" : "es"} \xB7 ${res.filesScanned} files scanned \xB7 ${res.containersDispatched} containers \xB7 ${res.durationMs}ms \xB7 ${res.stoppedReason}`
990
+ );
991
+ if (res.stoppedReason !== "completed") {
992
+ console.error(
993
+ `warning: search did not complete (${res.stoppedReason}); results may be partial`
994
+ );
995
+ }
996
+ }
997
+ process.exit(res.matches.length > 0 ? 0 : 1);
998
+ } catch (err) {
999
+ fail(err);
1000
+ }
1001
+ }
1002
+ );
635
1003
  var keys = program.command("api-keys").description("Manage Archil API keys (account-level credentials)");
636
1004
  keys.command("list").description("List API keys").option("--limit <n>", "Maximum number of keys to return", (v) => parseInt(v, 10)).action(async (opts) => {
637
1005
  try {
@@ -669,7 +1037,7 @@ keys.command("delete <id>").description("Delete an API key").action(async (id) =
669
1037
  }
670
1038
  });
671
1039
  function rewriteSugar(argv) {
672
- const known = /* @__PURE__ */ new Set(["list", "get", "create", "delete", "exec", "api-keys", "help", "--help", "-h", "--version", "-V"]);
1040
+ const known = /* @__PURE__ */ new Set(["list", "get", "create", "delete", "exec", "grep", "api-keys", "help", "--help", "-h", "--version", "-V"]);
673
1041
  const head = argv[2];
674
1042
  const next = argv[3];
675
1043
  if (head && next === "exec" && !known.has(head) && !head.startsWith("-")) {