promigence 0.1.0-beta.4 → 0.1.0-beta.6

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/dist/account.js CHANGED
@@ -333,7 +333,7 @@ function stateFlag(ctx, states) {
333
333
  }
334
334
  return wanted;
335
335
  }
336
- const BUILD_STATES = ["queued", "running", "verified", "rejected", "failed"];
336
+ const BUILD_STATES = ["queued", "running", "verified", "built", "rejected", "failed"];
337
337
  const RUN_STATES = ["running", "completed", "cap_killed", "aborted"];
338
338
  const snapshotBuilds = async (ctx) => {
339
339
  const wanted = stateFlag(ctx, BUILD_STATES);
package/dist/bin.js CHANGED
@@ -13,7 +13,7 @@ import { TEMPLATES_HANDLERS } from "./templates.js";
13
13
  import { ACCOUNT_HANDLERS } from "./account.js";
14
14
  import { MCP_HANDLERS } from "./mcp.js";
15
15
  import { AGENT_HANDLERS } from "./agent.js";
16
- import { CREDITS_HANDLERS } from "./credits.js";
16
+ import { CREDITS_HANDLERS, canAsk, claimOnRefusal } from "./credits.js";
17
17
  import { RUNNERS_HANDLERS } from "./runners.js";
18
18
  import { PRICING_HANDLERS } from "./pricing.js";
19
19
  import { GITHUB_HANDLERS } from "./github.js";
@@ -321,6 +321,11 @@ async function main(argv) {
321
321
  env.promigence_exit = exit;
322
322
  exit = e.code === "enoent" ? 127 : cmd.passthroughExit;
323
323
  }
324
+ if (e.code === "credit_unclaimed" && mode === "text" && canAsk()) {
325
+ const lines = await claimOnRefusal(ctx).catch(() => undefined);
326
+ if (lines)
327
+ return void (await emit(mode, env, lines, exit, false));
328
+ }
324
329
  await emit(mode, env, errorTextLines(cmd.name, e, exit, next), exit, !cmd.noGuidePointer);
325
330
  }
326
331
  }
@@ -99,5 +99,6 @@ export declare function outputTails(c: PromigenceClient, sandboxes: ReadonlyArra
99
99
  }>;
100
100
  }>): Promise<Map<string, OutputTail>>;
101
101
  export declare function positional(ctx: Ctx, what: string, example: string): string;
102
+ export declare function filesPutTarArgs(out: string, parent: string, base: string, noMetadata?: string[]): string[];
102
103
  export declare const HANDLERS: Readonly<Record<string, Handler>>;
103
104
  export {};
package/dist/commands.js CHANGED
@@ -3,11 +3,12 @@ import { createHash, randomBytes } from "node:crypto";
3
3
  import { closeSync, constants as fsConstants, existsSync, fstatSync, lstatSync, mkdirSync, mkdtempSync, openSync, readFileSync, readSync, renameSync, rmSync, statSync, writeSync, } from "node:fs";
4
4
  import { tmpdir } from "node:os";
5
5
  import { basename, dirname, join, resolve } from "node:path";
6
- import { DEFAULT_API_URL, DEFAULT_SNAPSHOT, ERRORS_DOCS_URL, MAX_OUTPUT_BYTES_DEFAULT, CONTEXT_GIT_RE, PromigenceClient, checkRepoUrl, withoutPin, PromigenceError, SpendCapError, TimeoutError, checkImageRef, contextRelative, credentialsPath, FILE_PUT_CHUNK_BYTES, imageSnapshot, isWaiting, execOutputBytes, packContext, readCredentials, } from "./sdk/index.js";
6
+ import { DEFAULT_API_URL, DEFAULT_SNAPSHOT, ERRORS_DOCS_URL, MAX_OUTPUT_BYTES_DEFAULT, CONTEXT_GIT_RE, PromigenceClient, checkRepoUrl, withoutPin, PromigenceError, SpendCapError, TimeoutError, checkImageRef, contextRelative, credentialsPath, FILE_PUT_CHUNK_BYTES, imageSnapshot, isWaiting, execOutputBytes, listContext, packContext, readCredentials, } from "./sdk/index.js";
7
7
  import { fmtMs, recordTiming } from "./timing.js";
8
8
  import { capacityLine, queuedClause, queuedLines, statusLine } from "./capacity.js";
9
- import { DevcontainerError, detectOne, load, planJson, planLines, planSetup, resolveGiven, translate, workspaceRootFor, wrapCommand, } from "./devcontainer.js";
9
+ import { DevcontainerError, detectOne, load, planJson, planLines, planSetup, resolveGiven, snapshotEnv, translate, workspaceRootFor, wrapCommand, } from "./devcontainer.js";
10
10
  import { detect, ecosystemOfCommands, imageCreateCommand, readConfig } from "./init.js";
11
+ import { CREDENTIAL_REMEDY, confirmOverride, credentialRefusal, emitUploadList, localTarNoMetadataArgs } from "./uploadguard.js";
11
12
  import { emit, errorEnvelope, errorTextLines, exitWith, forkLine, forkSummary, gradeLine, ms, noCommandFix, okEnvelope, parseMaxOutput, recordNotice, sandboxGrade, parseCommandTimeout, parseCount, parseDuration, parseUsd, pickExit, reportSummaryLines, toPromigenceError, totalsLine, usageError, write, writeBytes, } from "./io.js";
12
13
  import { buildPrice } from "./pricing.js";
13
14
  import { DOCS_URL, WAIT_TIMEOUT_DEFAULT } from "./surface.js";
@@ -366,7 +367,7 @@ const secretsSet = async (ctx) => {
366
367
  `${sh `promigence secrets set ${name}`}: stored ${res.bytes} bytes from ${source}`,
367
368
  ...(res.hosts?.length
368
369
  ? [
369
- ` bound to ${res.hosts.join(", ")}: the sandbox never sees its value (the variable holds ${res.placeholder ?? "a stand-in"})`,
370
+ ` bound to ${res.hosts.join(", ")}: the value stays out of the sandbox unless a run asks for it (the variable holds ${res.placeholder ?? "something else"})`,
370
371
  ...boundUseLines(res),
371
372
  ...(res.raw === "refused" ? [" its value can never be given to a sandbox"] : []),
372
373
  ]
@@ -440,7 +441,7 @@ function isBuilding(b) {
440
441
  return b.state === "queued" || b.state === "running";
441
442
  }
442
443
  function buildExit(b) {
443
- if (b.state === "verified")
444
+ if (b.state === "verified" || b.state === "built")
444
445
  return 0;
445
446
  if (b.state === "rejected")
446
447
  return 6;
@@ -463,7 +464,7 @@ export function verifyTiming(v) {
463
464
  function buildLines(b) {
464
465
  const note = b.deduped ? " (deduped: an identical build already ran)"
465
466
  : b.idempotent_replay === true ? " (reused: the same request already built it)" : "";
466
- const unverified = b.state === "verified" && b.verified === false;
467
+ const unverified = b.state === "built" || (b.state === "verified" && b.verified === false);
467
468
  const shown = unverified ? "built (no --verify: not verified)" : b.state;
468
469
  const lines = [`build ${b.build_id}: ${shown}${note}`];
469
470
  if (b.snapshot_id)
@@ -487,7 +488,7 @@ async function emitBuild(ctx, b, extra = {}, more = []) {
487
488
  const exit = buildExit(b);
488
489
  const next = b.snapshot_deleted === true
489
490
  ? nexts(["promigence snapshot list", "this build's snapshot was deleted; the snapshots you have now"])
490
- : b.snapshot_id && (b.state === "verified" || (!isBuilding(b) && b.state !== "rejected" && b.state !== "failed"))
491
+ : b.snapshot_id && (b.state === "verified" || b.state === "built" || (!isBuilding(b) && b.state !== "rejected" && b.state !== "failed"))
491
492
  ? nexts([sh `promigence run --snapshot ${b.snapshot_id} --count 2 --cap 1 -- CMD`, "fork it and run a command in every sandbox"], [sh `promigence snapshot inspect ${b.snapshot_id}`, "the manifest: provenance and verification"])
492
493
  : b.state === "running"
493
494
  ? nexts([sh `promigence snapshot status ${b.build_id} --wait`, "keep waiting; the build is untouched"])
@@ -643,7 +644,27 @@ function refuseRepoCredentials(ctx) {
643
644
  checkRepoUrl(withoutPin(context), "--context");
644
645
  }
645
646
  }
646
- function dockerfileOpts(ctx) {
647
+ async function packedContext(ctx, dir, path) {
648
+ const listed = listContext(dir, { dockerfile: path });
649
+ if (bool(ctx, "--list-files")) {
650
+ return emitUploadList(ctx, {
651
+ kind: "dockerfile", root: dir, paths: listed.paths, files: listed.files, bytes: listed.bytes,
652
+ secrets: listed.secrets, notes: listed.notes, ignoreFile: listed.ignoreFile,
653
+ });
654
+ }
655
+ if (listed.notes.length) {
656
+ await write(process.stderr, ctx.mode === "json"
657
+ ? jsonLine(process.stderr, { hint: "context", notes: listed.notes })
658
+ : listed.notes.map((n) => fieldForTerminal(process.stderr, ` note: ${n}`)).join("\n") + "\n");
659
+ }
660
+ if (listed.secrets.length && !bool(ctx, "--allow-secrets"))
661
+ throw credentialRefusal(listed.secrets, CREDENTIAL_REMEDY.dockerfile);
662
+ const secretsAllowed = await confirmOverride(ctx, {
663
+ flag: "--allow-secrets", what: "files that look like credentials", items: listed.secrets,
664
+ });
665
+ return { context: packContext(dir, { dockerfile: path, allowSecrets: true }), secretsAllowed };
666
+ }
667
+ async function dockerfileOpts(ctx) {
647
668
  const df = required(ctx, "--dockerfile", "Pass --dockerfile ./Dockerfile.");
648
669
  const context = str(ctx, "--context");
649
670
  if (context !== undefined && CONTEXT_GIT_RE.test(context.trim())) {
@@ -667,11 +688,17 @@ function dockerfileOpts(ctx) {
667
688
  if (!existsSync(df)) {
668
689
  throw usageError(`no Dockerfile at ${JSON.stringify(df)}`, "Pass --dockerfile with the path to your Dockerfile, or --context URL@SHA to build one from a repository.");
669
690
  }
670
- return { context: packContext(dir, { keep: [path] }), path };
691
+ const packed = await packedContext(ctx, dir, path);
692
+ return { context: packed.context, path, ...(packed.secretsAllowed.length ? { secretsAllowed: packed.secretsAllowed } : {}) };
671
693
  }
672
- function devcontainerCreate(ctx, plan) {
694
+ async function devcontainerCreate(ctx, plan) {
673
695
  let source;
674
696
  let label;
697
+ let secretsAllowed = [];
698
+ const localEnvBaked = await confirmOverride(ctx, {
699
+ flag: "--devcontainer-local-env", what: "values from your environment, baked into the snapshot",
700
+ items: plan.localEnvBaked ?? [],
701
+ });
675
702
  if (plan.source.kind === "image") {
676
703
  source = { image: plan.source.image };
677
704
  label = plan.source.image;
@@ -681,7 +708,9 @@ function devcontainerCreate(ctx, plan) {
681
708
  throw usageError(`the devcontainer names a Dockerfile that is not here: ${plan.source.dockerfile}`, "Check `build.dockerfile` and `build.context` in the devcontainer.json, relative to the file itself.");
682
709
  }
683
710
  const path = contextRelative(plan.source.context, plan.source.dockerfile);
684
- source = { dockerfile: { context: packContext(plan.source.context, { keep: [path] }), path } };
711
+ const packed = await packedContext(ctx, plan.source.context, path);
712
+ source = { dockerfile: { context: packed.context, path } };
713
+ secretsAllowed = packed.secretsAllowed;
685
714
  label = `${path} in ${plan.source.context}`;
686
715
  }
687
716
  const repo = str(ctx, "--repo");
@@ -693,7 +722,8 @@ function devcontainerCreate(ctx, plan) {
693
722
  throw usageError(`--commit must be a full 40-character sha, got ${JSON.stringify(commit)}`, "A branch is not a pin: pass --commit $(git rev-parse HEAD).");
694
723
  }
695
724
  const setup = planSetup(plan, { repo, commit, userSetup: list(ctx, "--setup") });
696
- const opts = { ...source, ...(setup.length ? { setup } : {}) };
725
+ const env = snapshotEnv(plan);
726
+ const opts = { ...source, ...(setup.length ? { setup } : {}), ...(env ? { env } : {}) };
697
727
  const verify = str(ctx, "--verify");
698
728
  if (verify !== undefined)
699
729
  opts.verify = { cmd: wrapCommand(plan, verify) };
@@ -709,6 +739,8 @@ function devcontainerCreate(ctx, plan) {
709
739
  devcontainer: json,
710
740
  ...(repo === undefined ? {} : { repo, commit }),
711
741
  ...(tier === undefined ? {} : { requested_tier: tier }),
742
+ ...(secretsAllowed.length ? { secret_files_allowed: secretsAllowed } : {}),
743
+ ...(localEnvBaked.length ? { local_env_baked: localEnvBaked } : {}),
712
744
  };
713
745
  const lines = planLines(plan, setup);
714
746
  if (repo !== undefined && commit !== undefined) {
@@ -739,7 +771,7 @@ const snapshotCreate = async (ctx) => {
739
771
  ({ opts, label, submitted, tier } = gh);
740
772
  }
741
773
  else if (plan !== undefined) {
742
- const built = devcontainerCreate(ctx, plan);
774
+ const built = await devcontainerCreate(ctx, plan);
743
775
  opts = built.opts;
744
776
  label = built.label;
745
777
  submitted = built.submitted;
@@ -767,11 +799,14 @@ const snapshotCreate = async (ctx) => {
767
799
  submitted = { build_price: null, image };
768
800
  }
769
801
  else {
770
- const dockerfile = dockerfileOpts(ctx);
802
+ const { secretsAllowed, ...dockerfile } = await dockerfileOpts(ctx);
771
803
  source = { dockerfile };
772
804
  const where = dockerfile.context.startsWith("tar;") ? (str(ctx, "--context") ?? dirname(str(ctx, "--dockerfile") ?? ".")) : dockerfile.context;
773
805
  label = `${dockerfile.path ?? "Dockerfile"} in ${where}`;
774
- submitted = { build_price: null, dockerfile: { path: dockerfile.path ?? "Dockerfile", context: where } };
806
+ submitted = {
807
+ build_price: null, dockerfile: { path: dockerfile.path ?? "Dockerfile", context: where },
808
+ ...(secretsAllowed?.length ? { secret_files_allowed: secretsAllowed } : {}),
809
+ };
775
810
  }
776
811
  const verify = str(ctx, "--verify");
777
812
  const start = str(ctx, "--start");
@@ -863,7 +898,7 @@ const snapshotCreate = async (ctx) => {
863
898
  .map((s) => ` also ${s.repo}@${s.commit.slice(0, 7)} -> ${s.path ?? "beside the workspace, under its own name"}`)
864
899
  .join("\n") + "\n");
865
900
  }
866
- if (started.deduped || started.state === "verified" || (started.snapshot_id && !isBuilding(started))) {
901
+ if (started.deduped || started.state === "verified" || started.state === "built" || (started.snapshot_id && !isBuilding(started))) {
867
902
  return emitBuild(ctx, started, submitted);
868
903
  }
869
904
  const joined = started.joined === true;
@@ -1575,7 +1610,7 @@ async function runImageSnapshot(ctx, c) {
1575
1610
  const buildOpts = { image, allowBuild: true };
1576
1611
  const snapshotId = await imageSnapshot(c, buildOpts, {
1577
1612
  onBuild: async (b) => {
1578
- const free = b.deduped === true || b.joined === true || b.idempotent_replay === true || b.state === "verified";
1613
+ const free = b.deduped === true || b.joined === true || b.idempotent_replay === true || b.state === "verified" || b.state === "built";
1579
1614
  charged = free ? 0 : (typeof b.build_price === "number" ? b.build_price : price);
1580
1615
  buildId = b.build_id;
1581
1616
  await write(process.stderr, ctx.mode === "json"
@@ -2306,7 +2341,7 @@ const snapshotVerify = async (ctx) => {
2306
2341
  return emit(ctx.mode, env, lines, exit, ctx.pointer);
2307
2342
  };
2308
2343
  function localTar(args, what) {
2309
- const r = spawnSync("tar", args, { encoding: "utf8" });
2344
+ const r = spawnSync("tar", args, { encoding: "utf8", env: { ...process.env, COPYFILE_DISABLE: "1" } });
2310
2345
  if (r.error && r.error.code === "ENOENT") {
2311
2346
  throw usageError("`tar` is not on PATH, and a directory transfer needs one locally", "Install tar (macOS and every Linux ship one), or move the files one at a time without --recursive.");
2312
2347
  }
@@ -2328,13 +2363,14 @@ const filesPut = async (ctx) => {
2328
2363
  const { sid, src, dst } = filesArgs(ctx);
2329
2364
  if (!dst.startsWith("/"))
2330
2365
  throw usageError(`DST ${dst} must be absolute inside the sandbox`, "Example: /tmp/fixture.json.");
2331
- const r = await withLocalFile(src, (source) => client(ctx).sandboxes.files.putFrom(sid, dst, source));
2366
+ const r = await withLocalFile(src, (source) => client(ctx).sandboxes.files.putFrom(sid, dst, source, source.mode === undefined ? {} : { mode: source.mode }));
2332
2367
  const next = nexts([sh `promigence exec --sandbox ${sid} -- ls -l ${dst}`, "see it in the sandbox"]);
2333
2368
  return emit(ctx.mode, okEnvelope(ctx.cmd.name, { sandbox_id: sid, src, dst, bytes: r.bytes, chunks: r.chunks, mode: r.mode }, next), [`${src} -> ${sid}:${dst} (${bytes(r.bytes)}${r.chunks > 1 ? `, ${r.chunks} chunks` : ""})`], 0, ctx.pointer);
2334
2369
  };
2335
2370
  async function withLocalFile(path, fn) {
2336
2371
  let fd;
2337
2372
  let size;
2373
+ let mode;
2338
2374
  try {
2339
2375
  fd = openSync(path, "r");
2340
2376
  const st = fstatSync(fd);
@@ -2343,6 +2379,7 @@ async function withLocalFile(path, fn) {
2343
2379
  throw Object.assign(new Error("it is a directory"), { code: "EISDIR" });
2344
2380
  }
2345
2381
  size = st.isFile() ? st.size : -1;
2382
+ mode = st.isFile() && process.platform !== "win32" ? st.mode & 0o777 : undefined;
2346
2383
  }
2347
2384
  catch (e) {
2348
2385
  const code = e.code;
@@ -2358,6 +2395,7 @@ async function withLocalFile(path, fn) {
2358
2395
  const buf = Buffer.alloc(FILE_PUT_CHUNK_BYTES);
2359
2396
  return await fn({
2360
2397
  size,
2398
+ mode,
2361
2399
  read: (offset, len) => {
2362
2400
  const want = Math.min(len, buf.length);
2363
2401
  let got = 0;
@@ -2375,6 +2413,9 @@ async function withLocalFile(path, fn) {
2375
2413
  closeSync(fd);
2376
2414
  }
2377
2415
  }
2416
+ export function filesPutTarArgs(out, parent, base, noMetadata = localTarNoMetadataArgs()) {
2417
+ return ["-c", "-f", out, ...noMetadata, "-C", parent, base];
2418
+ }
2378
2419
  const filesPutDir = async (ctx) => {
2379
2420
  const { sid, src, dst } = filesArgs(ctx);
2380
2421
  if (!dst.startsWith("/"))
@@ -2384,7 +2425,7 @@ const filesPutDir = async (ctx) => {
2384
2425
  const tmp = join(mkdtempSync(join(tmpdir(), "promigence-put-")), "dir.tar");
2385
2426
  const parent = dirname(resolve(src));
2386
2427
  const base = basename(resolve(src));
2387
- localTar(["cf", tmp, "-C", parent, base], `tarring ${src}`);
2428
+ localTar(filesPutTarArgs(tmp, parent, base), `tarring ${src}`);
2388
2429
  const bytesIn = statSync(tmp).size;
2389
2430
  const c = client(ctx);
2390
2431
  const scratch = `/tmp/.promigence-upload/cli-${Date.now().toString(36)}-${randomBytes(6).toString("hex")}.tar`;
@@ -0,0 +1,125 @@
1
+ import { PromigenceError } from "./sdk/index.js";
2
+ import { bool, nexts } from "./commands.js";
3
+ import { emit, okEnvelope, write } from "./io.js";
4
+ import { headless, openBrowser } from "./login.js";
5
+ import { jsonLine } from "./term.js";
6
+ const NAMES = { github: "GitHub", gitlab: "GitLab" };
7
+ const CONNECTED = new Set(["connected", "updated", "already_connected"]);
8
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
9
+ export async function consoleTicket(mint) {
10
+ try {
11
+ return await mint();
12
+ }
13
+ catch (e) {
14
+ if (e instanceof PromigenceError && e.code === "not_found")
15
+ return null;
16
+ throw e;
17
+ }
18
+ }
19
+ function endLine(host, r) {
20
+ const a = r.account ?? `the ${NAMES[host]} account`;
21
+ switch (r.state) {
22
+ case "connected":
23
+ return `connected: ${a}`;
24
+ case "updated":
25
+ return `already connected: ${a} (repository access updated)`;
26
+ case "already_connected":
27
+ return `already connected: ${a}`;
28
+ default:
29
+ return `${r.state}: ${r.message}`;
30
+ }
31
+ }
32
+ function fixFor(host, r) {
33
+ const again = `\`promigence ${host} connect\``;
34
+ const n = NAMES[host];
35
+ switch (r.state) {
36
+ case "wrong_user":
37
+ return `The page was opened in a browser signed in to another Promigence account, so nothing was started. Run ${again} again and open the page where you are signed in to the console as yourself.`;
38
+ case "declined":
39
+ return `The connection was declined, so nothing was connected. Run ${again} again to start over.`;
40
+ case "other_org":
41
+ return `That ${n} account is connected to another Promigence organization. Disconnect it there first (\`promigence ${host} disconnect\` with that organization's key), then run ${again} again.`;
42
+ case "needs_admin":
43
+ return `Connect it signed in to ${n} as an owner (admin) of that account, or ask one of its owners to; then run ${again} again.`;
44
+ case "not_installer":
45
+ return `The ${n} user who installed the App on that account can connect it now; an owner of the account can 15 minutes after the install. Then run ${again} again.`;
46
+ default:
47
+ return `Run ${again} again. If it fails the same way, write to support@promigence.ai with the time it happened.`;
48
+ }
49
+ }
50
+ export async function consoleConnect(ctx, host, o) {
51
+ const n = NAMES[host];
52
+ const url = o.ticket.connect_url;
53
+ const opened = bool(ctx, "--no-open") || headless() ? false : openBrowser(url);
54
+ const noWait = bool(ctx, "--no-wait");
55
+ if (ctx.mode === "json") {
56
+ await write(process.stderr, jsonLine(process.stderr, {
57
+ hint: "connect_url", connect_url: url, request_id: o.ticket.request_id, expires_at: o.ticket.expires_at, opened,
58
+ }));
59
+ }
60
+ else {
61
+ await write(process.stderr, [
62
+ opened
63
+ ? `Opening the Promigence console to connect ${n}…`
64
+ : `Open this page in a browser where you are signed in to the Promigence console (any device works):`,
65
+ ` ${opened ? "If it did not open: " : ""}${url}`,
66
+ host === "github"
67
+ ? " It starts the connection on GitHub, where you choose the account, and all repositories or only the ones you pick."
68
+ : " It starts the connection on GitLab, where you approve read-only access.",
69
+ " The page works once, within 5 minutes, and only signed in to the console as you, an owner of this organization: keep it to yourself.",
70
+ ...(noWait ? [] : [` Waiting up to ${o.waitText} (Ctrl-C stops waiting; the connection still finishes in the browser).`]),
71
+ ].join("\n") + "\n");
72
+ }
73
+ if (noWait) {
74
+ const next = nexts([`promigence ${host} status`, "once it is done: the connection"]);
75
+ return emit(ctx.mode, okEnvelope(ctx.cmd.name, {
76
+ connect_url: url, request_id: o.ticket.request_id, expires_at: o.ticket.expires_at, opened, waited: false, connected: [],
77
+ }, next), [`connect page: ${url}`, ` next: ${next[0].cmd}`], 0, ctx.pointer);
78
+ }
79
+ const t0 = Date.now();
80
+ let said = "";
81
+ for (;;) {
82
+ await sleep(Math.min(o.pollMs, Math.max(0, o.bound - (Date.now() - t0))));
83
+ const r = await o.poll();
84
+ if (r.state !== "pending")
85
+ return finish(ctx, host, r, { url, opened, waitedMs: Date.now() - t0, ...o });
86
+ if (r.message !== said && ctx.mode !== "json") {
87
+ said = r.message;
88
+ await write(process.stderr, ` ${r.message}\n`);
89
+ }
90
+ if (Date.now() - t0 >= o.bound) {
91
+ throw new PromigenceError({
92
+ code: "timeout",
93
+ message: `no ${n} connection finished within ${o.waitText}`,
94
+ fix: `Finish it in the browser if it is still open, then \`promigence ${host} status\`. Otherwise run \`promigence ${host} connect\` again for a new page (each works once, within 5 minutes).`,
95
+ exitCode: 11,
96
+ });
97
+ }
98
+ }
99
+ }
100
+ async function finish(ctx, host, r, o) {
101
+ const n = NAMES[host];
102
+ if (CONNECTED.has(r.state)) {
103
+ const connected = await o.connections(r);
104
+ const next = nexts(...o.okNext);
105
+ return emit(ctx.mode, okEnvelope(ctx.cmd.name, {
106
+ connect_url: o.url, opened: o.opened, waited: true, waited_ms: o.waitedMs, state: r.state, request: r, connected,
107
+ }, next), [endLine(host, r), ` ${r.message}`, ` next: ${next[0].cmd}`], 0, ctx.pointer);
108
+ }
109
+ if (r.state === "pending_provider_approval") {
110
+ const next = nexts([`promigence ${host} connect`, `once an owner of the account approved it on ${n}`]);
111
+ const line = `waiting for an owner of ${r.account ?? "that account"} to approve it on ${n}; run \`promigence ${host} connect\` again after`;
112
+ return emit(ctx.mode, okEnvelope(ctx.cmd.name, {
113
+ connect_url: o.url, opened: o.opened, waited: true, waited_ms: o.waitedMs, state: r.state, request: r, connected: [],
114
+ }, next), [line, ` ${r.message}`], 0, ctx.pointer);
115
+ }
116
+ if (r.state === "expired") {
117
+ throw new PromigenceError({
118
+ code: "timeout",
119
+ message: r.message,
120
+ fix: `Nothing was connected. Run \`promigence ${host} connect\` again and finish it in the browser: the page works within 5 minutes, the connection within 15.`,
121
+ exitCode: 11,
122
+ });
123
+ }
124
+ throw new PromigenceError({ code: "not_connected", message: `${r.state}: ${r.message}`, fix: fixFor(host, r), exitCode: 7 });
125
+ }
package/dist/credits.js CHANGED
@@ -1,7 +1,8 @@
1
+ import { createInterface } from "node:readline";
1
2
  import { PromigenceClient } from "./sdk/index.js";
2
3
  import { bool, clientOpts, nexts } from "./commands.js";
3
4
  import { deviceFetch, deviceHeader } from "./device.js";
4
- import { emit, okEnvelope, recordNotice } from "./io.js";
5
+ import { emit, okEnvelope, recordNotice, write } from "./io.js";
5
6
  import { headless, openBrowser } from "./login.js";
6
7
  import { usd } from "./money.js";
7
8
  export function dollars(n) {
@@ -104,6 +105,51 @@ export async function offerAfterLogin(ctx, apiUrl, apiKey, consoleHome) {
104
105
  lines: claimLines(offer, link, opened),
105
106
  };
106
107
  }
108
+ async function askLine(prompt) {
109
+ const rl = createInterface({ input: process.stdin, output: process.stderr });
110
+ try {
111
+ return await new Promise((resolve) => rl.question(prompt, resolve));
112
+ }
113
+ finally {
114
+ rl.close();
115
+ }
116
+ }
117
+ export function canAsk(env = process.env) {
118
+ const ci = (env["CI"] ?? "").trim().toLowerCase();
119
+ return Boolean(process.stdin.isTTY && process.stderr.isTTY) && (ci === "" || ci === "0" || ci === "false");
120
+ }
121
+ export async function claimOnRefusal(ctx, io = {}) {
122
+ const opts = { ...clientOpts(ctx), onNotice: recordNotice };
123
+ let o;
124
+ try {
125
+ o = await getOffer(new PromigenceClient(opts));
126
+ }
127
+ catch {
128
+ return undefined;
129
+ }
130
+ if (!o || o.state !== "claimable")
131
+ return undefined;
132
+ const credit = typeof o.credit_usd === "number" ? `${dollars(o.credit_usd)} of free credit` : "free credit";
133
+ await write(process.stderr, `This account has ${credit} waiting to be claimed; nothing was started or charged.\n`);
134
+ const answer = await (io.ask ?? askLine)("Claim it now? It opens the claim page in your browser, where you accept the terms. [y/N] ");
135
+ if (!/^\s*y(es)?\s*$/i.test(answer))
136
+ return ["Not claimed. When you are ready: promigence credits claim"];
137
+ let link;
138
+ try {
139
+ link = await getClaimLink(claimClient(opts));
140
+ if (!link || typeof link.url !== "string")
141
+ throw new Error("no claim link in the answer");
142
+ }
143
+ catch (e) {
144
+ const err = e;
145
+ return [
146
+ `Could not open the claim page: ${err?.message ?? String(e)}`,
147
+ ` Try again: promigence credits claim${err?.code === "forbidden" ? " (owners; members ask an owner)" : ""}`,
148
+ ];
149
+ }
150
+ const opened = maybeOpen(ctx, link.url);
151
+ return [...claimLines(o, link, opened), "Once it is claimed, run the command again."];
152
+ }
107
153
  async function cardsOff(c, o) {
108
154
  if (!(typeof o.card_bonus_usd === "number" && o.card_bonus_usd > 0) || o.state === "none")
109
155
  return false;
@@ -68,6 +68,7 @@ export interface Plan {
68
68
  tierReason: string | undefined;
69
69
  ignored: IgnoredProperty[];
70
70
  notes: string[];
71
+ localEnvBaked?: string[];
71
72
  }
72
73
  export interface TranslateOptions {
73
74
  workspaceRoot: string;
@@ -82,6 +83,7 @@ export declare function sizeGib(v: unknown, field: string, file: string): number
82
83
  export declare function lifecycleSteps(v: unknown, field: string, ctx: SubstitutionContext, file: string): LifecycleStep[];
83
84
  export declare function shellQuote(s: string): string;
84
85
  export declare function wrapCommand(plan: Plan, command: string): string;
86
+ export declare function snapshotEnv(plan: Plan): Record<string, string> | undefined;
85
87
  export interface SetupOptions {
86
88
  repo?: string | undefined;
87
89
  commit?: string | undefined;
@@ -270,8 +270,8 @@ export function substituteString(input, ctx) {
270
270
  if (!ctx.allowContainerEnv) {
271
271
  throw refuse("container_env_scope", `\${containerEnv:…} in ${ctx.property}: the spec defines it for remoteEnv only`, "Move the value into remoteEnv, or write it out literally.", ctx.file);
272
272
  }
273
- const [key, fallback] = splitDefault(arg ?? "");
274
- return ctx.containerEnv[key] ?? fallback;
273
+ const [key] = splitDefault(arg ?? "");
274
+ return ctx.containerEnv[key] ?? whole;
275
275
  }
276
276
  default: return whole;
277
277
  }
@@ -368,7 +368,7 @@ export function translate(loaded, opts) {
368
368
  const remoteEnv = stringMap(raw["remoteEnv"], "remoteEnv", file, (s, p) => substituteString(s, ctxFor(p, containerEnv, true)));
369
369
  const env = { ...containerEnv, ...remoteEnv };
370
370
  if (Object.keys(remoteEnv).length) {
371
- notes.push("remoteEnv is applied the same way as containerEnv: both end up in the environment your commands run in");
371
+ notes.push("remoteEnv is applied the same way as containerEnv: both are set for every command, terminal and SSH session in the snapshot's sandboxes");
372
372
  }
373
373
  let source;
374
374
  if (opts.imageOverride === undefined) {
@@ -468,6 +468,7 @@ export function translate(loaded, opts) {
468
468
  tierReason,
469
469
  ignored,
470
470
  notes,
471
+ ...(readFromHost.size > 0 ? { localEnvBaked: [...readFromHost].sort() } : {}),
471
472
  };
472
473
  }
473
474
  function refuseFeatures(raw, file) {
@@ -675,20 +676,14 @@ export function shellQuote(s) {
675
676
  return `'${s.replace(/'/g, `'\\''`)}'`;
676
677
  }
677
678
  export function wrapCommand(plan, command) {
678
- const exports = Object.entries(plan.env).map(([k, v]) => `${k}=${shellQuote(v)}`);
679
- const inner = [
680
- `cd ${shellQuote(plan.workspaceFolder)}`,
681
- ...(exports.length ? [`export ${exports.join(" ")}`] : []),
682
- command,
683
- ].join(" && ");
679
+ const inner = [`cd ${shellQuote(plan.workspaceFolder)}`, command].join(" && ");
684
680
  return plan.user === undefined || plan.user === "root" ? inner : `su ${plan.user} -c ${shellQuote(inner)}`;
685
681
  }
682
+ export function snapshotEnv(plan) {
683
+ return Object.keys(plan.env).length ? { ...plan.env } : undefined;
684
+ }
686
685
  export function planSetup(plan, opts = {}) {
687
686
  const steps = [];
688
- const envLines = Object.entries(plan.env).map(([k, v]) => shellQuote(`${k}=${v}`));
689
- if (envLines.length) {
690
- steps.push(`printf '%s\\n' ${envLines.join(" ")} >> /etc/environment`);
691
- }
692
687
  if (opts.repo && opts.commit) {
693
688
  const ws = shellQuote(plan.workspaceFolder);
694
689
  const clone = [