@minnovate/shadow-publisher 0.10.3 → 0.11.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.
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "git-publisher",
3
3
  "displayName": "Git Publisher",
4
- "version": "0.10.3",
4
+ "version": "0.11.1",
5
5
  "description": "Publish an app folder's Git history to the serverless Git publishing backend and keep it updated (\"publish my app\", \"update my app\", \"fetch the engineered version\").",
6
6
  "author": { "name": "git-publisher project" },
7
7
  "keywords": ["git", "publish", "deploy", "serverless"]
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  name: publish-app
3
3
  description: Publish or update the user's app (a folder) with the publisher. Use when the user says "publish my app", "update my app", "save my changes", "push my changes", "save this version online", or asks to resolve an interrupted publication. Handles first-time setup, every later update, interruption recovery and conflicts by running the `publisher` command; never uses git to commit or push.
4
- allowed-tools: Bash(publisher *), Bash(npm install -g @minnovate/shadow-publisher*), Bash(git status *), Bash(git diff *), Bash(git log *)
4
+ allowed-tools: Bash(publisher *), Bash(mkdir -p *), Bash(npm install -g @minnovate/shadow-publisher*), Bash(git status *), Bash(git diff *), Bash(git log *)
5
5
  ---
6
6
 
7
7
  # Publish or update the user's app
@@ -10,6 +10,9 @@ You publish the user's app folder with the `publisher` command. All saving, merg
10
10
  retry and resume logic lives in it; your job is to run it, read its `--json` output, and tell the
11
11
  user what happened in plain language. Never explain S3, packs, commits, sessions or tokens.
12
12
 
13
+ **When the user asks for Shadow or the publisher, use it at once.** Do not look at other ways to
14
+ host or share the app first.
15
+
13
16
  **`publisher sync` is how a change is saved and published. Never run `git add`, `git commit`,
14
17
  `git push` or `git stash` in the user's app folder**, and never add a git remote. `sync` records the
15
18
  folder's changes itself — and leaves out `.env` files, keys, `node_modules` and anything larger than
@@ -25,10 +28,25 @@ Every command accepts `--json` and prints exactly one JSON object on stdout. Exi
25
28
  publisher --version
26
29
  ```
27
30
 
28
- If the command is not found, install it: `npm install -g @minnovate/shadow-publisher` (it needs
29
- Node.js 22). In a Claude Cowork task this is normal: the sandbox starts empty every time, so install it
30
- without asking. On the person's own computer, say in one line that you are installing the
31
- publisher. If `npm` itself is missing, tell them Node.js 22 must be installed first, and stop.
31
+ If the command is not found, or the version is older than 0.11.0, install it:
32
+ `npm install -g @minnovate/shadow-publisher@latest` (it needs Node.js 22). In a Claude Cowork task
33
+ this is normal: the sandbox starts empty every time, so install it without asking. On the person's
34
+ own computer, say in one line that you are installing the publisher. If `npm` itself is missing,
35
+ tell them Node.js 22 must be installed first, and stop.
36
+
37
+ ### In a sandbox: keep the sign-in where the next command finds it
38
+
39
+ A sandbox (Claude Cowork, Claude Code on the web, a chat's code tool) can forget every file outside
40
+ its working folder between two commands. A sign-in kept in `/root/.config` is then gone at the
41
+ next command. So in a sandbox, do this from the start:
42
+
43
+ - Put `GIT_PUBLISHER_CREDENTIALS=<working folder>/.shadow-publisher/credentials.json` in front of
44
+ **every** `publisher` command. Use the absolute path of the folder the task works in. Never use
45
+ `/root` or `/tmp`, and never a path inside the app folder.
46
+ - Do nothing about `TMPDIR`. From 0.11.0 the publisher uses `/tmp` when `TMPDIR` names a folder that
47
+ does not exist, and says so in one line.
48
+
49
+ On the person's own computer, use neither: the default place is kept.
32
50
 
33
51
  ```bash
34
52
  publisher doctor --json
@@ -49,25 +67,30 @@ publisher doctor --json
49
67
 
50
68
  ### Signing in
51
69
 
52
- `publisher login` waits until the person has confirmed a code in their browser, and your shell may
53
- show nothing until a command ends. So never run it in the foreground. Start it in the background with
54
- its output going to a file, and read the file:
70
+ `publisher login` waits until the code it prints is approved, and your shell may show nothing until
71
+ a command ends. So sign in with two short commands instead of one that waits:
55
72
 
56
73
  ```bash
57
- publisher login --json > /tmp/publisher-login.log 2>&1 &
74
+ publisher login --no-wait --json
58
75
  ```
59
76
 
60
- Within a few seconds the log shows `To sign in, open:` with a link under it, and `and confirm the
61
- code:` with the code under it. Give the person both, as a link they can click, and say the page will ask them to sign in with their work account.
62
- Then read the log every few seconds until it holds a JSON object (`ok: true`), for up to ten minutes.
63
- In a Claude Cowork task this happens again in every new task, because the sandbox forgets the
64
- sign-in when the task ends.
77
+ It prints one JSON object with `userCode` and `verificationUri`, and `next.command`. Then:
78
+
79
+ 1. **If a tool named `approve_publisher_sign_in` is available** (the Shadow connector, which the
80
+ person connected in Claude's settings), call it with `userCode`. Do not give them the link.
81
+ If the tool answers that it did not approve, do step 2 instead.
82
+ 2. Otherwise give the person `verificationUri` as a link they can click, and `userCode`, and say
83
+ the page will ask them to sign in with their work account.
84
+ 3. Run `next.command` — it is `publisher login --continue --json`. It returns `ok: true` within
85
+ seconds of the approval. If it returns `state: "pending"`, the code is not approved yet: wait
86
+ for the person, then run it again. It refuses after ten minutes; then start again.
87
+ 4. **Check that the sign-in was kept before you publish**: run `publisher doctor --json` as a NEW
88
+ command. `next.action` must be `"ready"`. If it is not, or a command says the sign-in or the
89
+ waiting sign-in was not kept (its message names the file it looked for), this environment
90
+ forgot the file: keep it in another folder, as the section above says, and sign in again.
65
91
 
66
- **If a tool named `approve_publisher_sign_in` is available** (the Shadow publisher connector, which
67
- the person connected in Claude's settings), do not give them the link: call that tool with the code
68
- from the log, then keep reading the log as above — it reaches `ok: true` within a few seconds, with
69
- nothing for the person to do. If the tool answers that it did not approve, give them the link and
70
- code as above. The tool never returns a credential, and you never need one.
92
+ In a Claude Cowork task this happens again in every new task, because the sandbox forgets the
93
+ sign-in when the task ends. The tool never returns a credential, and you never need one.
71
94
 
72
95
  **Never ask the user for a token, an endpoint, a URL or a credential — in any words.** They are
73
96
  publishing an app; they do not know what those are, and you do not need them: `login` discovers
@@ -78,16 +101,27 @@ have; when it is not null it is already phrased for them, so pass it on rather t
78
101
  If they name an environment ("publish to dev"), pass `--profile dev` on each command rather than
79
102
  changing their default.
80
103
 
81
- ## 2. First publication of a folder
104
+ ## 2. A new app: make it before you write it
105
+
106
+ When the person asks for a NEW app, make it before you write a file. Do step 1 first, then:
107
+
108
+ ```bash
109
+ mkdir -p <folder>
110
+ publisher init <folder> --name "<app name>" --json
111
+ ```
112
+
113
+ `init` makes the app and links the folder to it. It publishes nothing and starts no checks. While
114
+ you write the app, the platform prepares its hosting, so the first publication goes live in
115
+ seconds, not in a minute. The folder must exist, and it can be empty. Its output names the app
116
+ under `appId`. Then write the app into that folder, and publish it with step 3, without `--name`.
82
117
 
83
- There is no separate first step: run step 3 in the app folder, adding `--name "<app name>"` the
84
- first time. A folder that is not published yet gets its application and repository from that same
85
- `sync` (the portal makes the application first, in one request), and is then published.
118
+ If the app is already written and was never published, `init` gains nothing. Run step 3 in the
119
+ app folder and add `--name "<app name>"`: that one `sync` makes the app, and then publishes it.
86
120
 
87
121
  The first `sync` prepares the folder with an explicit file selection: it excludes `.git`,
88
122
  `node_modules`, build caches, `.env*`, keys/certificates and files larger than 50 MB, and it
89
- respects `.gitignore`. Show the user the `linked` result and the excluded files it reports so they
90
- know what was left out (the exclusion rules are a safety net, not a secret scanner).
123
+ respects `.gitignore`. Show the user the excluded files it reports so they know what was left out
124
+ (the exclusion rules are a safety net, not a secret scanner).
91
125
 
92
126
  ## 3. Every publish, the first one included
93
127
 
@@ -96,7 +130,10 @@ publisher sync . --message "<a few words on what changed>" --until-change --time
96
130
  ```
97
131
 
98
132
  That one command records what changed in the folder, brings in anything published meanwhile (a
99
- fast-forward or a clean merge — it never rewrites the user's work), and publishes the result. Write
133
+ fast-forward or a clean merge — it never rewrites the user's work), and publishes the result. Its
134
+ `timings` say how long each stage took (milliseconds): `discovery`, `authentication`, `link`,
135
+ `prepare`, `upload`, and — once the app is followed — `received`, `checks` and `deployment`. Quote
136
+ them when the person asks why it was slow. Write
100
137
  the message in the user's words ("Add a contact page"), not git's.
101
138
 
102
139
  A published app runs with no internet access: **it may not load anything from other sites** — no
@@ -205,7 +242,7 @@ publisher list --json
205
242
 
206
243
  `apps[]` lists what they published, newest first, each with its `name` and a `checkout` command.
207
244
  Ask which one only if the name does not settle it, then run its `checkout` command and work in that
208
- folder. Never `sync` an empty new folder in its place: that makes a second app.
245
+ folder. Never `init` or `sync` an empty new folder in its place: that makes a second app.
209
246
 
210
247
  ## If the service cannot be reached
211
248
 
@@ -28,7 +28,7 @@ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__ge
28
28
  ));
29
29
 
30
30
  // src/main.ts
31
- var import_node_path8 = require("node:path");
31
+ var import_node_path9 = require("node:path");
32
32
 
33
33
  // ../protocol/src/versions.ts
34
34
  var PROTOCOL_VERSION = 1;
@@ -2957,6 +2957,7 @@ var MAX_WAIT_SECONDS = 5;
2957
2957
  var import_node_child_process = require("node:child_process");
2958
2958
  var import_node_os = require("node:os");
2959
2959
  var import_promises = require("node:fs/promises");
2960
+ var import_node_fs = require("node:fs");
2960
2961
  var import_node_path = require("node:path");
2961
2962
  var import_node_util = require("node:util");
2962
2963
  var import_node_child_process2 = require("node:child_process");
@@ -2991,6 +2992,23 @@ var GIT_BIN = process.env["GITPUB_GIT_BIN"] ?? "git";
2991
2992
  function neutralCwd() {
2992
2993
  return (0, import_node_os.tmpdir)();
2993
2994
  }
2995
+ function startFailure(label, error2, cwd) {
2996
+ const opts = { retryable: true, cause: error2 };
2997
+ if (error2.code !== "ENOENT") return new PublisherError("UNAVAILABLE", `${label} could not be started: ${error2.message}`, opts);
2998
+ if (!isDirectory(cwd)) {
2999
+ const which = cwd === (0, import_node_os.tmpdir)() ? "the temporary directory (TMPDIR)" : "its working directory";
3000
+ return new PublisherError("UNAVAILABLE", `${label} could not be started: ${which} ${cwd} does not exist`, { ...opts, details: { missing: "cwd", cwd } });
3001
+ }
3002
+ const where = GIT_BIN.includes("/") ? `at ${GIT_BIN}` : "on PATH";
3003
+ return new PublisherError("UNAVAILABLE", `${label} could not be started: git was not found ${where}`, { ...opts, details: { missing: "git", git: GIT_BIN } });
3004
+ }
3005
+ function isDirectory(path) {
3006
+ try {
3007
+ return (0, import_node_fs.statSync)(path).isDirectory();
3008
+ } catch {
3009
+ return false;
3010
+ }
3011
+ }
2994
3012
  var gitStats = { invocations: 0, totalMs: 0 };
2995
3013
  var liveChildren = /* @__PURE__ */ new Set();
2996
3014
  var closedChildren = /* @__PURE__ */ new WeakSet();
@@ -3158,11 +3176,12 @@ function spawnTracked(args, opts) {
3158
3176
  if (typeof a !== "string") throw new Error("git args must be strings");
3159
3177
  if (a.includes("\0")) throw new PublisherError("INVALID_REQUEST", "NUL in git argument");
3160
3178
  }
3161
- const child = spawnImpl(GIT_BIN, args, { cwd: opts.cwd ?? neutralCwd(), env: sanitizedGitEnv(opts.env), stdio: ["pipe", "pipe", "pipe"], windowsHide: true, detached: true });
3179
+ const cwd = opts.cwd ?? neutralCwd();
3180
+ const child = spawnImpl(GIT_BIN, args, { cwd, env: sanitizedGitEnv(opts.env), stdio: ["pipe", "pipe", "pipe"], windowsHide: true, detached: true });
3162
3181
  gitStats.invocations++;
3163
3182
  const cleanupBoundMs = opts.cleanupBoundMs ?? CLEANUP_BOUND_MS;
3164
3183
  track(child, cleanupBoundMs);
3165
- return { child, cleanupBoundMs };
3184
+ return { child, cleanupBoundMs, cwd };
3166
3185
  }
3167
3186
  function feedStdin(child, input, onSourceError) {
3168
3187
  let unpipe = () => void 0;
@@ -3211,7 +3230,7 @@ async function runGit(args, opts = {}) {
3211
3230
  const started = Date.now();
3212
3231
  const timeoutMs = opts.timeoutMs ?? 12e4;
3213
3232
  const maxOut = opts.maxStdoutBytes ?? 64 * 1024 * 1024;
3214
- const { child, cleanupBoundMs } = spawnTracked(args, opts);
3233
+ const { child, cleanupBoundMs, cwd } = spawnTracked(args, opts);
3215
3234
  child.once("exit", () => armCloseBound(child, cleanupBoundMs));
3216
3235
  const stdoutChunks = [];
3217
3236
  let outBytes = 0;
@@ -3261,7 +3280,7 @@ async function runGit(args, opts = {}) {
3261
3280
  const result = { code, stdout: Buffer.concat(stdoutChunks), stderr, durationMs };
3262
3281
  const cleanup = await reapChild(child, { boundMs: cleanupBoundMs, ...opts.groupObserver ? { observer: opts.groupObserver } : {} });
3263
3282
  if (!reapSucceeded(cleanup)) throw cleanupFailure(`git ${args[0]}`, cleanup, killedReason ?? void 0);
3264
- if (ended.error) throw new PublisherError("UNAVAILABLE", `git ${args[0]} could not be started: ${ended.error.message}`, { retryable: true, cause: ended.error });
3283
+ if (ended.error) throw startFailure(`git ${args[0]}`, ended.error, cwd);
3265
3284
  if (killedReason) throw killedError(killedReason, killedDetail, args, timeoutMs, maxOut);
3266
3285
  if (code !== 0 && !opts.allowFailure) throw new GitCommandError(args, code, stderr, opts.label);
3267
3286
  return result;
@@ -3334,8 +3353,8 @@ async function scanProcessGroup(pgid, signal) {
3334
3353
  if (signal?.aborted) throw new Error("process-group scan abandoned by its caller");
3335
3354
  if (!/^\d+$/.test(name)) continue;
3336
3355
  try {
3337
- const stat3 = await (0, import_promises.readFile)(`/proc/${name}/stat`, "utf8");
3338
- const rest = stat3.slice(stat3.lastIndexOf(")") + 2).split(" ");
3356
+ const stat4 = await (0, import_promises.readFile)(`/proc/${name}/stat`, "utf8");
3357
+ const rest = stat4.slice(stat4.lastIndexOf(")") + 2).split(" ");
3339
3358
  if (Number(rest[2]) !== pgid) continue;
3340
3359
  const status = await (0, import_promises.readFile)(`/proc/${name}/status`, "utf8");
3341
3360
  members2.push({ pid: Number(name), rss: Number(/^VmRSS:\s+(\d+)/m.exec(status)?.[1] ?? 0) * 1024 });
@@ -3524,13 +3543,13 @@ function compareVersions(a, b) {
3524
3543
 
3525
3544
  // ../git-core/src/hash.ts
3526
3545
  var import_node_crypto2 = require("node:crypto");
3527
- var import_node_fs = require("node:fs");
3546
+ var import_node_fs2 = require("node:fs");
3528
3547
  var import_promises2 = require("node:fs/promises");
3529
3548
  async function sha256File(path) {
3530
3549
  const h = (0, import_node_crypto2.createHash)("sha256");
3531
3550
  let size = 0;
3532
3551
  await new Promise((resolve3, reject) => {
3533
- (0, import_node_fs.createReadStream)(path).on("data", (c) => {
3552
+ (0, import_node_fs2.createReadStream)(path).on("data", (c) => {
3534
3553
  const b = typeof c === "string" ? Buffer.from(c) : c;
3535
3554
  size += b.length;
3536
3555
  h.update(b);
@@ -3542,12 +3561,12 @@ async function sha256File(path) {
3542
3561
  }
3543
3562
 
3544
3563
  // ../git-core/src/bare-repo.ts
3545
- var import_node_fs2 = require("node:fs");
3564
+ var import_node_fs3 = require("node:fs");
3546
3565
  async function indexPackFromFile(dir, packPath, opts = {}) {
3547
3566
  const args = ["index-pack", "--stdin", ...opts.keep ?? true ? ["--keep=gitpub"] : []];
3548
3567
  if (opts.strict ?? true) args.push("--strict", "--fsck-objects");
3549
3568
  if (opts.maxInputBytes) args.push(`--max-input-size=${opts.maxInputBytes}`);
3550
- const run = { cwd: dir, input: (0, import_node_fs2.createReadStream)(packPath), label: "index-pack", ...opts };
3569
+ const run = { cwd: dir, input: (0, import_node_fs3.createReadStream)(packPath), label: "index-pack", ...opts };
3551
3570
  const res = await runGit(args, { ...run, allowFailure: true });
3552
3571
  if (res.code !== 0) {
3553
3572
  const msg = res.stderr.trim().split("\n").pop() ?? "index-pack failed";
@@ -3614,14 +3633,14 @@ function revParse(dir, rev) {
3614
3633
  }
3615
3634
 
3616
3635
  // ../git-core/src/pack.ts
3617
- var import_node_fs3 = require("node:fs");
3636
+ var import_node_fs4 = require("node:fs");
3618
3637
  async function buildPack(repoDir, opts) {
3619
3638
  return packObjectsToFile(repoDir, ["--revs"], [...opts.include, ...opts.exclude.map((e) => `^${e}`)], opts);
3620
3639
  }
3621
3640
  async function packObjectsToFile(repoDir, extraArgs, stdinLines, opts) {
3622
3641
  const args = ["pack-objects", ...extraArgs, "--stdout", "-q", "--delta-base-offset"];
3623
3642
  const child = spawnGit(args, { cwd: repoDir, ...opts.cleanupBoundMs !== void 0 ? { cleanupBoundMs: opts.cleanupBoundMs } : {} });
3624
- const out = (0, import_node_fs3.createWriteStream)(opts.outPath);
3643
+ const out = (0, import_node_fs4.createWriteStream)(opts.outPath);
3625
3644
  let stderr = "";
3626
3645
  child.stderr.on("data", (c) => stderr += c.toString());
3627
3646
  const timeoutMs = opts.timeoutMs ?? 6e5;
@@ -3652,7 +3671,7 @@ async function packObjectsToFile(repoDir, extraArgs, stdinLines, opts) {
3652
3671
  }
3653
3672
  }
3654
3673
  const code = ended.code;
3655
- if (ended.error) throw new PublisherError("UNAVAILABLE", `git pack-objects could not be started: ${ended.error.message}`, { retryable: true, cause: ended.error });
3674
+ if (ended.error) throw startFailure("git pack-objects", ended.error, repoDir);
3656
3675
  if (opts.signal?.aborted) throw new PublisherError("CANCELLED", "pack creation cancelled");
3657
3676
  if (timedOut) throw new PublisherError("GIT", `pack-objects timed out after ${timeoutMs}ms`, { retryable: true });
3658
3677
  if (code !== 0) throw new PublisherError("GIT", `pack-objects failed: ${stderr.trim().split("\n").pop() ?? ""}`);
@@ -4255,9 +4274,43 @@ async function postForm(base, path, fields, fetchImpl = globalThis.fetch) {
4255
4274
  // ../client/src/credentials.ts
4256
4275
  var REFRESH_EARLY_MS = 6e4;
4257
4276
  function credentialsPath() {
4258
- if (process.env["GIT_PUBLISHER_CREDENTIALS"]) return process.env["GIT_PUBLISHER_CREDENTIALS"];
4259
- const base = process.env["XDG_CONFIG_HOME"] ?? (0, import_node_path4.join)((0, import_node_os2.homedir)(), ".config");
4260
- return (0, import_node_path4.join)(base, "git-publisher", "credentials.json");
4277
+ return credentialsLocation().path;
4278
+ }
4279
+ function credentialsLocation() {
4280
+ const explicit = process.env["GIT_PUBLISHER_CREDENTIALS"];
4281
+ if (explicit) return { path: explicit, from: "GIT_PUBLISHER_CREDENTIALS" };
4282
+ const xdg = process.env["XDG_CONFIG_HOME"];
4283
+ return { path: (0, import_node_path4.join)(xdg ?? (0, import_node_path4.join)((0, import_node_os2.homedir)(), ".config"), "git-publisher", "credentials.json"), from: xdg ? "XDG_CONFIG_HOME" : "HOME" };
4284
+ }
4285
+ async function credentialsFileState() {
4286
+ let raw;
4287
+ try {
4288
+ raw = await (0, import_promises5.readFile)(credentialsPath(), "utf8");
4289
+ } catch (e) {
4290
+ return e.code === "ENOENT" ? "absent" : "unreadable";
4291
+ }
4292
+ try {
4293
+ JSON.parse(raw);
4294
+ return "present";
4295
+ } catch {
4296
+ return "unparseable";
4297
+ }
4298
+ }
4299
+ async function whereCredentialsWere() {
4300
+ const { path, from } = credentialsLocation();
4301
+ const state = await credentialsFileState();
4302
+ const set = from === "HOME" ? "the default under HOME" : `set by ${from}`;
4303
+ const kept = state === "absent" ? ` ${notKept('"publisher login" answered ok', "sign in again")}` : "";
4304
+ return { sentence: `${WHAT_IS_THERE[state]} ${path} (${set}).${kept}`, details: { path, from, state } };
4305
+ }
4306
+ var WHAT_IS_THERE = {
4307
+ absent: "there is no credentials file at",
4308
+ present: "no connection is saved in",
4309
+ unreadable: "the credentials file cannot be read at",
4310
+ unparseable: "the credentials file cannot be parsed at"
4311
+ };
4312
+ function notKept(earlier, then) {
4313
+ return `If ${earlier} in an earlier command, this environment did not keep that file: set GIT_PUBLISHER_CREDENTIALS to a file in a folder that is kept between commands, outside the app folder, and ${then}.`;
4261
4314
  }
4262
4315
  function normalize(endpoint) {
4263
4316
  return endpoint.replace(/\/+$/, "");
@@ -4305,11 +4358,13 @@ async function readCredentials() {
4305
4358
  }
4306
4359
  var writes = 0;
4307
4360
  async function writeCredentials(file) {
4308
- const path = credentialsPath();
4361
+ return writeSecretJson(credentialsPath(), file);
4362
+ }
4363
+ async function writeSecretJson(path, value) {
4309
4364
  await (0, import_promises5.mkdir)((0, import_node_path4.dirname)(path), { recursive: true, mode: 448 });
4310
4365
  const tmp = `${path}.${process.pid}.${++writes}.tmp`;
4311
4366
  try {
4312
- await (0, import_promises5.writeFile)(tmp, JSON.stringify(file, null, 2) + "\n", { mode: 384 });
4367
+ await (0, import_promises5.writeFile)(tmp, JSON.stringify(value, null, 2) + "\n", { mode: 384 });
4313
4368
  await (0, import_promises5.chmod)(tmp, 384);
4314
4369
  await (0, import_promises5.rename)(tmp, path);
4315
4370
  } catch (e) {
@@ -4458,7 +4513,12 @@ async function resolveToken(endpoint, explicit, opts = {}) {
4458
4513
  if (given) return given;
4459
4514
  const url2 = normalize(endpoint);
4460
4515
  const entry = (await readCredentials()).endpoints[url2];
4461
- if (!entry) throw new PublisherError("UNAUTHORIZED", `no credentials for ${endpoint}; run "publisher login" or set GIT_PUBLISHER_TOKEN`, { details: { signIn: "missing" } });
4516
+ if (!entry) {
4517
+ const where = await whereCredentialsWere();
4518
+ throw new PublisherError("UNAUTHORIZED", `no credentials for ${endpoint}: ${where.sentence} Run "publisher login" or set GIT_PUBLISHER_TOKEN.`, {
4519
+ details: { signIn: "missing", credentialsFile: where.details }
4520
+ });
4521
+ }
4462
4522
  const now = (opts.now ?? Date.now)();
4463
4523
  const lapsed = entry.expiresAt !== void 0 && Date.parse(entry.expiresAt) - REFRESH_EARLY_MS <= now;
4464
4524
  if (entry.refreshToken && entry.authEndpoint && lapsed) return refreshCredential(url2, entry, opts);
@@ -5104,7 +5164,7 @@ async function checkIgnore(dir, paths) {
5104
5164
  }
5105
5165
 
5106
5166
  // ../client/src/fetch.ts
5107
- var import_node_fs5 = require("node:fs");
5167
+ var import_node_fs6 = require("node:fs");
5108
5168
  var import_promises8 = require("node:fs/promises");
5109
5169
  var import_node_os3 = require("node:os");
5110
5170
  var import_node_path6 = require("node:path");
@@ -5116,12 +5176,12 @@ var import_node_crypto4 = require("node:crypto");
5116
5176
  var import_promises7 = require("node:fs/promises");
5117
5177
 
5118
5178
  // ../client/src/upload.ts
5119
- var import_node_fs4 = require("node:fs");
5179
+ var import_node_fs5 = require("node:fs");
5120
5180
  var import_node_stream = require("node:stream");
5121
5181
  async function uploadPack(fetchImpl, upload, packPath, size, opts) {
5122
5182
  if (new Date(upload.expiresAt).getTime() < Date.now() + 5e3) return "expired";
5123
5183
  let sent = 0;
5124
- const file = (0, import_node_fs4.createReadStream)(packPath);
5184
+ const file = (0, import_node_fs5.createReadStream)(packPath);
5125
5185
  const progress = new TransformStream({
5126
5186
  transform(chunk, controller) {
5127
5187
  sent += chunk.byteLength;
@@ -5526,7 +5586,7 @@ async function download(fetchImpl, url2, dest, sha256, size, call2) {
5526
5586
  controller.enqueue(chunk);
5527
5587
  }
5528
5588
  });
5529
- await (0, import_promises9.pipeline)(import_node_stream2.Readable.fromWeb(res.body.pipeThrough(counted)), (0, import_node_fs5.createWriteStream)(dest));
5589
+ await (0, import_promises9.pipeline)(import_node_stream2.Readable.fromWeb(res.body.pipeThrough(counted)), (0, import_node_fs6.createWriteStream)(dest));
5530
5590
  if (n !== size || hash.digest("hex") !== sha256) throw new PublisherError("NETWORK", "downloaded artifact failed checksum verification", { retryable: true });
5531
5591
  return n;
5532
5592
  }
@@ -5829,10 +5889,8 @@ async function migrateAssociation(opts) {
5829
5889
  var DEVICE_CODE_GRANT = "urn:ietf:params:oauth:grant-type:device_code";
5830
5890
  var str = (v) => typeof v === "string" && v.length > 0 ? v : null;
5831
5891
  var num = (v, fallback) => typeof v === "number" && Number.isFinite(v) && v > 0 ? v : fallback;
5832
- async function deviceLogin(opts) {
5892
+ async function startDeviceLogin(opts) {
5833
5893
  const doFetch = opts.fetch ?? globalThis.fetch;
5834
- const sleep2 = opts.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
5835
- const now = opts.now ?? Date.now;
5836
5894
  const control = normalize(opts.endpoint);
5837
5895
  let advertised;
5838
5896
  try {
@@ -5843,8 +5901,7 @@ async function deviceLogin(opts) {
5843
5901
  if (typeof advertised !== "string" || advertised === "") throw new PublisherError("UNAVAILABLE", "this deployment does not offer device login; obtain a token another way");
5844
5902
  const base = normalize(advertised);
5845
5903
  assertCredentialEndpoint(base);
5846
- const post2 = (path, fields) => postForm(base, path, fields, doFetch);
5847
- const started = await post2("/v1/auth/device", { client_id: opts.clientId ?? "gitpub-cli" });
5904
+ const started = await postForm(base, "/v1/auth/device", { client_id: opts.clientId ?? "gitpub-cli" }, doFetch);
5848
5905
  if (started.status !== 200) throw new PublisherError("UNAVAILABLE", `starting the login failed (${started.status})`, { retryable: true });
5849
5906
  const s = started.body;
5850
5907
  const deviceCode = str(s.device_code);
@@ -5852,17 +5909,33 @@ async function deviceLogin(opts) {
5852
5909
  const uri = str(s.verification_uri_complete) ?? str(s.verification_uri);
5853
5910
  if (!deviceCode || !userCode || !uri) throw new PublisherError("UNAVAILABLE", "the login response was incomplete");
5854
5911
  const expiresInSeconds = num(s.expires_in, 600);
5855
- let intervalMs = num(s.interval, 5) * 1e3;
5856
- await opts.onPrompt({ userCode, verificationUri: uri, expiresInSeconds, intervalSeconds: intervalMs / 1e3 });
5857
- const deadline = now() + expiresInSeconds * 1e3;
5912
+ return {
5913
+ deviceCode,
5914
+ userCode,
5915
+ verificationUri: uri,
5916
+ expiresInSeconds,
5917
+ intervalSeconds: num(s.interval, 5),
5918
+ authEndpoint: base,
5919
+ expiresAt: (opts.now ?? Date.now)() + expiresInSeconds * 1e3
5920
+ };
5921
+ }
5922
+ async function pollDeviceLogin(started, opts) {
5923
+ const doFetch = opts.fetch ?? globalThis.fetch;
5924
+ const sleep2 = opts.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
5925
+ const now = opts.now ?? Date.now;
5926
+ let intervalMs = started.intervalSeconds * 1e3;
5927
+ let last = opts.lastPolledAt;
5858
5928
  for (; ; ) {
5859
- await sleep2(intervalMs);
5860
- if (now() >= deadline) throw new PublisherError("EXPIRED", "the login expired before it was approved");
5861
- const polled = await post2("/v1/auth/token", { grant_type: DEVICE_CODE_GRANT, device_code: deviceCode });
5929
+ const at = Math.max(now(), last === null ? 0 : last + intervalMs);
5930
+ if (opts.until !== void 0 && at > opts.until) return null;
5931
+ if (at > now()) await sleep2(at - now());
5932
+ if (now() >= started.expiresAt) throw new PublisherError("EXPIRED", "the login expired before it was approved");
5933
+ const polled = await postForm(started.authEndpoint, "/v1/auth/token", { grant_type: DEVICE_CODE_GRANT, device_code: started.deviceCode }, doFetch);
5934
+ last = now();
5862
5935
  if (polled.status === 200) {
5863
5936
  const token = str(polled.body["access_token"]);
5864
5937
  if (!token) throw new PublisherError("UNAVAILABLE", "the login returned no credential");
5865
- return { token, refreshToken: str(polled.body["refresh_token"]), expiresInSeconds: num(polled.body["expires_in"], 900), authEndpoint: base };
5938
+ return { token, refreshToken: str(polled.body["refresh_token"]), expiresInSeconds: num(polled.body["expires_in"], 900), authEndpoint: started.authEndpoint };
5866
5939
  }
5867
5940
  const error2 = str(polled.body["error"]);
5868
5941
  if (error2 === "authorization_pending") continue;
@@ -5876,33 +5949,44 @@ async function deviceLogin(opts) {
5876
5949
  throw new PublisherError("UNAVAILABLE", `the login failed (${polled.status}${error2 ? `: ${error2}` : ""})`);
5877
5950
  }
5878
5951
  }
5952
+ async function deviceLogin(opts) {
5953
+ const started = await startDeviceLogin(opts);
5954
+ const { deviceCode: _secret, authEndpoint: _auth, expiresAt: _at, ...shown2 } = started;
5955
+ await opts.onPrompt(shown2);
5956
+ const signIn = await pollDeviceLogin(started, { ...opts, lastPolledAt: (opts.now ?? Date.now)() });
5957
+ return signIn;
5958
+ }
5879
5959
 
5880
- // src/discover.ts
5881
- var DEFAULT_PORTAL_URL = "https://shadow.minnovate.ai";
5882
- function portalBase(env, flag) {
5883
- return (flag ?? env["GIT_PUBLISHER_PORTAL"] ?? DEFAULT_PORTAL_URL).replace(/\/+$/, "");
5960
+ // ../client/src/pending-login.ts
5961
+ var import_promises10 = require("node:fs/promises");
5962
+ var import_node_path7 = require("node:path");
5963
+ function pendingLoginPath() {
5964
+ return (0, import_node_path7.join)((0, import_node_path7.dirname)(credentialsPath()), "login-pending.json");
5884
5965
  }
5885
- async function discoverEndpoint(portal, fetchImpl = fetch) {
5886
- const url2 = `${portal}/api/v1/config`;
5887
- let body;
5966
+ async function savePendingLogin(pending) {
5967
+ return writeSecretJson(pendingLoginPath(), { version: 1, ...pending });
5968
+ }
5969
+ async function readPendingLogin() {
5970
+ let raw;
5888
5971
  try {
5889
- const response = await fetchImpl(url2);
5890
- if (!response.ok) {
5891
- throw new PublisherError("UNAVAILABLE", `could not ask ${portal} where to publish (it answered ${response.status})`, { retryable: true });
5892
- }
5893
- body = await response.json();
5894
- } catch (e) {
5895
- if (PublisherError.isPublisherError(e)) throw e;
5896
- throw new PublisherError("NETWORK", `could not reach ${portal}: ${e instanceof Error ? e.message : String(e)}`, { retryable: true });
5897
- }
5898
- const endpoint = body?.publishingEndpoint;
5899
- if (endpoint === null || endpoint === void 0) {
5900
- throw new PublisherError("LOCAL_STATE", `${portal} is not set up for publishing; ask whoever runs it, or pass --endpoint`);
5901
- }
5902
- if (typeof endpoint !== "string" || endpoint.trim() === "") {
5903
- throw new PublisherError("UNAVAILABLE", `${portal} answered with something this version does not understand`, { retryable: false });
5972
+ raw = JSON.parse(await (0, import_promises10.readFile)(pendingLoginPath(), "utf8"));
5973
+ } catch {
5974
+ return null;
5904
5975
  }
5905
- return endpoint;
5976
+ if (typeof raw !== "object" || raw === null) return null;
5977
+ const r = raw;
5978
+ const s = r.started;
5979
+ if (r.version !== 1 || typeof r.endpoint !== "string" || !s) return null;
5980
+ if (typeof s.deviceCode !== "string" || typeof s.authEndpoint !== "string" || typeof s.expiresAt !== "number" || typeof s.intervalSeconds !== "number") return null;
5981
+ return {
5982
+ endpoint: r.endpoint,
5983
+ started: s,
5984
+ ...typeof r.profile === "string" ? { profile: r.profile } : {},
5985
+ ...r.setDefault === true ? { setDefault: true } : {}
5986
+ };
5987
+ }
5988
+ async function removePendingLogin() {
5989
+ await (0, import_promises10.rm)(pendingLoginPath(), { force: true });
5906
5990
  }
5907
5991
 
5908
5992
  // src/git-minimum.ts
@@ -6132,15 +6216,81 @@ async function repositoryForInit(p, endpoint, name, opts = {}) {
6132
6216
  return { repoId: made.repository.repoId, appId: made.appId };
6133
6217
  }
6134
6218
 
6135
- // src/login.ts
6136
- async function verifiedCapabilities(publisher) {
6137
- const caps = await publisher.getCapabilities();
6138
- await publisher.listRepositories({ limit: 1 });
6139
- return caps;
6219
+ // src/args.ts
6220
+ var import_node_util2 = require("node:util");
6221
+ var OPTIONS = {
6222
+ endpoint: { type: "string" },
6223
+ profile: { type: "string" },
6224
+ token: { type: "string" },
6225
+ portal: { type: "string" },
6226
+ name: { type: "string" },
6227
+ repo: { type: "string" },
6228
+ message: { type: "string" },
6229
+ publication: { type: "string" },
6230
+ timeout: { type: "string" },
6231
+ branch: { type: "string" },
6232
+ ours: { type: "string" },
6233
+ theirs: { type: "string" },
6234
+ to: { type: "string" },
6235
+ cursor: { type: "string" },
6236
+ ref: { type: "string", multiple: true },
6237
+ json: { type: "boolean" },
6238
+ quiet: { type: "boolean" },
6239
+ force: { type: "boolean" },
6240
+ apply: { type: "boolean" },
6241
+ "mark-conflicts": { type: "boolean" },
6242
+ help: { type: "boolean" },
6243
+ version: { type: "boolean" },
6244
+ "set-default": { type: "boolean" },
6245
+ archived: { type: "boolean" },
6246
+ // A flag of its own, not a negation of --wait: there is no --wait.
6247
+ "no-wait": { type: "boolean" },
6248
+ "until-change": { type: "boolean" },
6249
+ // `login --continue`: finish the sign-in a `login --no-wait` started.
6250
+ continue: { type: "boolean" }
6251
+ };
6252
+ function str2(v) {
6253
+ return typeof v === "string" ? v : void 0;
6254
+ }
6255
+ function parseArgs(argv) {
6256
+ try {
6257
+ const { positionals, values } = (0, import_node_util2.parseArgs)({ args: argv, options: OPTIONS, allowPositionals: true, strict: true });
6258
+ return { cmd: positionals, flags: { ...values } };
6259
+ } catch (e) {
6260
+ throw new PublisherError("INVALID_REQUEST", e instanceof Error ? e.message : String(e));
6261
+ }
6262
+ }
6263
+
6264
+ // src/discover.ts
6265
+ var DEFAULT_PORTAL_URL = "https://shadow.minnovate.ai";
6266
+ function portalBase(env, flag) {
6267
+ return (flag ?? env["GIT_PUBLISHER_PORTAL"] ?? DEFAULT_PORTAL_URL).replace(/\/+$/, "");
6268
+ }
6269
+ async function discoverEndpoint(portal, fetchImpl = fetch) {
6270
+ const url2 = `${portal}/api/v1/config`;
6271
+ let body;
6272
+ try {
6273
+ const response = await fetchImpl(url2);
6274
+ if (!response.ok) {
6275
+ throw new PublisherError("UNAVAILABLE", `could not ask ${portal} where to publish (it answered ${response.status})`, { retryable: true });
6276
+ }
6277
+ body = await response.json();
6278
+ } catch (e) {
6279
+ if (PublisherError.isPublisherError(e)) throw e;
6280
+ throw new PublisherError("NETWORK", `could not reach ${portal}: ${e instanceof Error ? e.message : String(e)}`, { retryable: true });
6281
+ }
6282
+ const endpoint = body?.publishingEndpoint;
6283
+ if (endpoint === null || endpoint === void 0) {
6284
+ throw new PublisherError("LOCAL_STATE", `${portal} is not set up for publishing; ask whoever runs it, or pass --endpoint`);
6285
+ }
6286
+ if (typeof endpoint !== "string" || endpoint.trim() === "") {
6287
+ throw new PublisherError("UNAVAILABLE", `${portal} answered with something this version does not understand`, { retryable: false });
6288
+ }
6289
+ return endpoint;
6140
6290
  }
6141
6291
 
6142
6292
  // src/prompt.ts
6143
- var import_promises10 = require("node:readline/promises");
6293
+ var import_promises11 = require("node:readline/promises");
6144
6294
  function isInteractive(env) {
6145
6295
  return env.isTTY === true && !env.json;
6146
6296
  }
@@ -6157,7 +6307,7 @@ function resolveChoice(answer, names) {
6157
6307
  return names.find((n) => n.toLowerCase() === a.toLowerCase()) ?? null;
6158
6308
  }
6159
6309
  async function ask3(question) {
6160
- const rl = (0, import_promises10.createInterface)({ input: process.stdin, output: process.stderr });
6310
+ const rl = (0, import_promises11.createInterface)({ input: process.stdin, output: process.stderr });
6161
6311
  try {
6162
6312
  return await rl.question(question);
6163
6313
  } catch {
@@ -6202,58 +6352,205 @@ Profile name: `);
6202
6352
  return null;
6203
6353
  }
6204
6354
 
6205
- // src/args.ts
6206
- var import_node_util2 = require("node:util");
6207
- var OPTIONS = {
6208
- endpoint: { type: "string" },
6209
- profile: { type: "string" },
6210
- token: { type: "string" },
6211
- portal: { type: "string" },
6212
- name: { type: "string" },
6213
- repo: { type: "string" },
6214
- message: { type: "string" },
6215
- publication: { type: "string" },
6216
- timeout: { type: "string" },
6217
- branch: { type: "string" },
6218
- ours: { type: "string" },
6219
- theirs: { type: "string" },
6220
- to: { type: "string" },
6221
- cursor: { type: "string" },
6222
- ref: { type: "string", multiple: true },
6223
- json: { type: "boolean" },
6224
- quiet: { type: "boolean" },
6225
- force: { type: "boolean" },
6226
- apply: { type: "boolean" },
6227
- "mark-conflicts": { type: "boolean" },
6228
- help: { type: "boolean" },
6229
- version: { type: "boolean" },
6230
- "set-default": { type: "boolean" },
6231
- archived: { type: "boolean" },
6232
- // A flag of its own, not a negation of --wait: there is no --wait.
6233
- "no-wait": { type: "boolean" },
6234
- "until-change": { type: "boolean" }
6235
- };
6236
- function parseArgs(argv) {
6355
+ // src/timings.ts
6356
+ function stopwatch(now = Date.now) {
6357
+ const started = now();
6358
+ let last = started;
6359
+ const laps = {};
6360
+ return {
6361
+ /** `reached` false records a stage this command did not go through: null, and no time taken. */
6362
+ lap(name, reached = true) {
6363
+ const t = now();
6364
+ if (!reached) {
6365
+ laps[name] ??= null;
6366
+ return t;
6367
+ }
6368
+ laps[name] = (laps[name] ?? 0) + (t - last);
6369
+ last = t;
6370
+ return t;
6371
+ },
6372
+ /** Every lap so far, with `total` since the stopwatch started. */
6373
+ read(extra = {}) {
6374
+ return { ...laps, ...extra, total: now() - started };
6375
+ }
6376
+ };
6377
+ }
6378
+ function splitAtBegin(clock, onProgress) {
6379
+ let sending = false;
6380
+ return {
6381
+ onProgress(e) {
6382
+ if (!sending && e.phase === "begin") {
6383
+ sending = true;
6384
+ clock.lap("prepare");
6385
+ }
6386
+ onProgress(e);
6387
+ },
6388
+ done: () => sending ? clock.lap("upload") : (clock.lap("prepare"), null)
6389
+ };
6390
+ }
6391
+ function between(from, to) {
6392
+ const a = from?.at ? Date.parse(from.at) : NaN;
6393
+ const b = to?.at ? Date.parse(to.at) : NaN;
6394
+ return Number.isFinite(a) && Number.isFinite(b) && to.state !== "skipped" ? Math.max(0, b - a) : null;
6395
+ }
6396
+ function runTimings(stages, acceptedAt) {
6397
+ const s = (name) => stages?.find((e) => e.stage === name);
6398
+ const receivedAt = s("received")?.at ? Date.parse(s("received").at) : NaN;
6399
+ return {
6400
+ handoff: acceptedAt !== null && Number.isFinite(receivedAt) ? Math.max(0, receivedAt - acceptedAt) : null,
6401
+ received: between(s("received"), s("running")),
6402
+ checks: between(s("running"), s("deploying")),
6403
+ deployment: between(s("deploying"), s("live"))
6404
+ };
6405
+ }
6406
+ function duration3(ms) {
6407
+ return ms < 1e4 ? `${(Math.max(0, ms) / 1e3).toFixed(1)} s` : took(ms);
6408
+ }
6409
+ function timingsLine(t) {
6410
+ const parts = Object.entries(t).filter((e) => e[1] !== null).map(([name, ms]) => `${name} ${duration3(ms)}`);
6411
+ return `Took: ${parts.join(" \xB7 ")}`;
6412
+ }
6413
+
6414
+ // src/login.ts
6415
+ async function verifiedCapabilities(publisher) {
6416
+ const caps = await publisher.getCapabilities();
6417
+ await publisher.listRepositories({ limit: 1 });
6418
+ return caps;
6419
+ }
6420
+ var CONTINUE = "publisher login --continue --json";
6421
+ var START_AGAIN = "publisher login --no-wait --json";
6422
+ function prompt({ verificationUri, userCode, expiresInSeconds }) {
6423
+ process.stderr.write(
6424
+ `
6425
+ To sign in, open:
6426
+ ${verificationUri}
6427
+
6428
+ and confirm the code:
6429
+ ${userCode}
6430
+
6431
+ Waiting (it expires in ${Math.round(expiresInSeconds / 60)} minutes)...
6432
+ `
6433
+ );
6434
+ }
6435
+ async function login(ctx) {
6436
+ if (ctx.flags["continue"] === true) return continueLogin(ctx);
6437
+ const clock = stopwatch();
6438
+ const target = resolveLoginEndpoint(ctx.input);
6439
+ const endpoint = "endpoint" in target ? target.endpoint : await discoverEndpoint(portalBase(process.env, str2(ctx.flags["portal"])));
6440
+ if (!endpoint) throw new PublisherError("INVALID_REQUEST", "login requires --endpoint (or --profile naming a stored one)");
6441
+ clock.lap("discovery");
6442
+ const profile = str2(ctx.flags["profile"]);
6443
+ const setDefault = ctx.flags["set-default"] === true;
6444
+ const given = str2(ctx.flags["token"]) ?? process.env["GIT_PUBLISHER_TOKEN"];
6445
+ if (given) return finish(ctx, { endpoint, token: given, signIn: null, profile, setDefault }, clock);
6446
+ if (ctx.flags["no-wait"] === true) {
6447
+ const started = await startDeviceLogin({ endpoint });
6448
+ clock.lap("start");
6449
+ prompt(started);
6450
+ const pendingFile = await savePendingLogin({ endpoint, started, ...profile ? { profile } : {}, ...setDefault ? { setDefault } : {} });
6451
+ ctx.out(pendingResult(endpoint, started, "Have the code approved, then run next.command to finish signing in.", clock, { pendingFile }));
6452
+ return 0;
6453
+ }
6454
+ const signIn = await deviceLogin({
6455
+ endpoint,
6456
+ onPrompt: (shown2) => {
6457
+ clock.lap("start");
6458
+ prompt(shown2);
6459
+ }
6460
+ });
6461
+ clock.lap("approval");
6462
+ return finish(ctx, { endpoint, token: signIn.token, signIn, profile, setDefault }, clock);
6463
+ }
6464
+ async function continueLogin(ctx) {
6465
+ const clock = stopwatch();
6466
+ const pending = await readPendingLogin();
6467
+ if (pending === null) {
6468
+ const pendingFile = pendingLoginPath();
6469
+ throw new PublisherError("LOCAL_STATE", `no sign-in is waiting at ${pendingFile}. ${notKept(`"${START_AGAIN}" answered`, `run ${START_AGAIN} again`)}`, {
6470
+ details: { pendingFile, credentialsFile: credentialsLocation(), next: { action: "sign-in", ask: null, command: START_AGAIN, profiles: [] } }
6471
+ });
6472
+ }
6473
+ const timeout = str2(ctx.flags["timeout"]);
6474
+ let signIn;
6237
6475
  try {
6238
- const { positionals, values } = (0, import_node_util2.parseArgs)({ args: argv, options: OPTIONS, allowPositionals: true, strict: true });
6239
- return { cmd: positionals, flags: { ...values } };
6476
+ signIn = await pollDeviceLogin(pending.started, { lastPolledAt: null, ...timeout === void 0 ? {} : { until: Date.now() + followTimeoutMs(timeout) } });
6240
6477
  } catch (e) {
6241
- throw new PublisherError("INVALID_REQUEST", e instanceof Error ? e.message : String(e));
6478
+ if (e instanceof PublisherError && (e.code === "EXPIRED" || e.code === "FORBIDDEN")) await removePendingLogin();
6479
+ throw e;
6242
6480
  }
6481
+ clock.lap("approval");
6482
+ if (signIn === null) {
6483
+ ctx.out(pendingResult(pending.endpoint, pending.started, "The code is not approved yet. Have it approved, then run next.command again.", clock));
6484
+ return 3;
6485
+ }
6486
+ const code = await finish(
6487
+ ctx,
6488
+ { endpoint: pending.endpoint, token: signIn.token, signIn, profile: pending.profile ?? str2(ctx.flags["profile"]), setDefault: pending.setDefault === true },
6489
+ clock
6490
+ );
6491
+ await removePendingLogin();
6492
+ return code;
6493
+ }
6494
+ function pendingResult(endpoint, started, hint, clock, extra = {}) {
6495
+ return {
6496
+ ok: false,
6497
+ state: "pending",
6498
+ endpoint,
6499
+ userCode: started.userCode,
6500
+ verificationUri: started.verificationUri,
6501
+ expiresAt: new Date(started.expiresAt).toISOString(),
6502
+ ...extra,
6503
+ next: { action: "approve", ask: null, command: CONTINUE },
6504
+ hint,
6505
+ timings: clock.read()
6506
+ };
6507
+ }
6508
+ async function finish(ctx, got, clock) {
6509
+ const { endpoint, token, signIn } = got;
6510
+ const caps = await verifiedCapabilities(createPublisher({ endpoint, token }));
6511
+ clock.lap("verify");
6512
+ let profile = got.profile;
6513
+ if (!profile && isInteractive({ json: ctx.json, isTTY: process.stdin.isTTY })) {
6514
+ const suggested = suggestProfileName(endpoint, new Set(Object.keys(ctx.input.file.profiles)));
6515
+ profile = await askProfileName(endpoint, suggested) ?? void 0;
6516
+ }
6517
+ const path = await saveCredential(endpoint, token, void 0, {
6518
+ ...profile ? { profile } : {},
6519
+ setDefault: got.setDefault,
6520
+ // The refresh half, when the service issued one: the sign-in outlives the credential.
6521
+ ...signIn ? { signIn } : {}
6522
+ });
6523
+ const file = await readCredentials();
6524
+ clock.lap("save");
6525
+ const named = profileForEndpoint(file, endpoint, profile);
6526
+ const timings = clock.read();
6527
+ ctx.log(timingsLine(timings));
6528
+ ctx.out({
6529
+ ok: true,
6530
+ endpoint,
6531
+ profile: named,
6532
+ isDefault: named !== null && file.defaultProfile === named,
6533
+ server: caps.server,
6534
+ credentialsFile: path,
6535
+ // Which setting chose that path: a sandbox that keeps only its workspace needs it to be there.
6536
+ credentialsFrom: credentialsLocation().from,
6537
+ timings
6538
+ });
6539
+ return 0;
6243
6540
  }
6244
6541
 
6245
6542
  // src/skills.ts
6246
6543
  var import_node_child_process3 = require("node:child_process");
6247
- var import_node_fs6 = require("node:fs");
6248
- var import_node_path7 = require("node:path");
6544
+ var import_node_fs7 = require("node:fs");
6545
+ var import_node_path8 = require("node:path");
6249
6546
  var MARKETPLACE = "shadow-publisher";
6250
6547
  var PLUGIN = "git-publisher";
6251
6548
  function packageRoot() {
6252
- return (0, import_node_path7.join)((0, import_node_path7.dirname)((0, import_node_fs6.realpathSync)(process.argv[1])), "..");
6549
+ return (0, import_node_path8.join)((0, import_node_path8.dirname)((0, import_node_fs7.realpathSync)(process.argv[1])), "..");
6253
6550
  }
6254
6551
  function packageVersion() {
6255
6552
  try {
6256
- return JSON.parse((0, import_node_fs6.readFileSync)((0, import_node_path7.join)(packageRoot(), "package.json"), "utf8")).version;
6553
+ return JSON.parse((0, import_node_fs7.readFileSync)((0, import_node_path8.join)(packageRoot(), "package.json"), "utf8")).version;
6257
6554
  } catch {
6258
6555
  return "unknown";
6259
6556
  }
@@ -6270,8 +6567,8 @@ function claude(args) {
6270
6567
  });
6271
6568
  }
6272
6569
  async function installSkills({ log }) {
6273
- const source = (0, import_node_path7.join)(packageRoot(), "dist", "claude");
6274
- if (!(0, import_node_fs6.existsSync)((0, import_node_path7.join)(source, ".claude-plugin", "marketplace.json"))) {
6570
+ const source = (0, import_node_path8.join)(packageRoot(), "dist", "claude");
6571
+ if (!(0, import_node_fs7.existsSync)((0, import_node_path8.join)(source, ".claude-plugin", "marketplace.json"))) {
6275
6572
  throw new PublisherError("LOCAL_STATE", `this publisher carries no skills at ${source}; reinstall it with: npm install -g @minnovate/shadow-publisher`);
6276
6573
  }
6277
6574
  const step = async (what, args) => {
@@ -6369,6 +6666,46 @@ function hintFor(view) {
6369
6666
  return `It is waiting for your answer. Run: publisher answer ${how}`;
6370
6667
  }
6371
6668
 
6669
+ // src/tempdir.ts
6670
+ var import_node_fs8 = require("node:fs");
6671
+ var import_promises12 = require("node:fs/promises");
6672
+ var import_node_os4 = require("node:os");
6673
+ var FALLBACK_TEMP_DIRS = ["/tmp", "/var/tmp"];
6674
+ async function unusable(path) {
6675
+ try {
6676
+ if (!(await (0, import_promises12.stat)(path)).isDirectory()) return "is not a directory";
6677
+ } catch {
6678
+ return "does not exist";
6679
+ }
6680
+ try {
6681
+ await (0, import_promises12.access)(path, import_node_fs8.constants.W_OK | import_node_fs8.constants.X_OK);
6682
+ return null;
6683
+ } catch {
6684
+ return "cannot be written";
6685
+ }
6686
+ }
6687
+ async function ensureTempDir(env = process.env, fallbacks = FALLBACK_TEMP_DIRS) {
6688
+ const named = (0, import_node_os4.tmpdir)();
6689
+ const reason = await unusable(named);
6690
+ if (reason === null) return { path: named, replaced: null };
6691
+ for (const candidate of fallbacks) {
6692
+ if (candidate !== named && await unusable(candidate) === null) {
6693
+ env["TMPDIR"] = candidate;
6694
+ return { path: candidate, replaced: { path: named, reason } };
6695
+ }
6696
+ }
6697
+ throw new PublisherError(
6698
+ "LOCAL_STATE",
6699
+ `the temporary directory ${named} ${reason}, and neither ${fallbacks.join(" nor ")} can be used: set TMPDIR to a directory that can be written`,
6700
+ {
6701
+ details: { tempDir: named, reason, tried: [...fallbacks] }
6702
+ }
6703
+ );
6704
+ }
6705
+ function replacedLine(t) {
6706
+ return t.replaced === null ? null : `note: the temporary directory ${t.replaced.path} ${t.replaced.reason}; using ${t.path} instead`;
6707
+ }
6708
+
6372
6709
  // src/main.ts
6373
6710
  var USAGE = `publisher \u2014 publish an app's Git history to the serverless Git backend
6374
6711
 
@@ -6382,6 +6719,8 @@ Usage:
6382
6719
  [--endpoint <url>] skip discovery and use this one
6383
6720
  [--portal <url>] ask a different platform (or GIT_PUBLISHER_PORTAL)
6384
6721
  [--profile <name>] [--set-default] [--token <token>]
6722
+ [--no-wait] print the code and exit; finish with login --continue
6723
+ publisher login --continue [--timeout <seconds>] collect the credential once the code is approved
6385
6724
  publisher logout (--endpoint <url> | --profile <name>)
6386
6725
  publisher profile list | profile use <name> | profile remove <name>
6387
6726
  publisher init [dir] [--name <name>] [--repo <id>] create (or attach to) a repository, associate this folder
@@ -6438,9 +6777,6 @@ function optStr(flags, key) {
6438
6777
  const v = str2(flags[key]);
6439
6778
  return v ? { [key]: v } : {};
6440
6779
  }
6441
- function str2(v) {
6442
- return typeof v === "string" ? v : void 0;
6443
- }
6444
6780
  async function main(argv) {
6445
6781
  const { cmd, flags } = parseArgs(argv);
6446
6782
  const json = flags["json"] === true;
@@ -6455,7 +6791,7 @@ async function main(argv) {
6455
6791
  process.stderr.write(`\r[${e.phase}] ${e.message}${pct} `);
6456
6792
  if (e.phase === "done") process.stderr.write("\n");
6457
6793
  };
6458
- const dirArg = (i) => (0, import_node_path8.resolve)(cmd[i] ?? ".");
6794
+ const dirArg = (i) => (0, import_node_path9.resolve)(cmd[i] ?? ".");
6459
6795
  const following = { ...stageEmitter(json, quiet), log, untilChange: flags["until-change"] === true };
6460
6796
  const resolutionInput = async (dir) => {
6461
6797
  let association;
@@ -6483,7 +6819,11 @@ async function main(argv) {
6483
6819
  const client = async (dir) => {
6484
6820
  const { endpoint, source } = await endpointFor(dir);
6485
6821
  const token = str2(flags["token"]);
6486
- return { endpoint, p: createPublisher({ endpoint, endpointSource: source, ...token ? { token } : {}, clientName: "git-publisher-cli", clientVersion: CLIENT_VERSION }) };
6822
+ return {
6823
+ endpoint,
6824
+ source,
6825
+ p: createPublisher({ endpoint, endpointSource: source, ...token ? { token } : {}, clientName: "git-publisher-cli", clientVersion: CLIENT_VERSION })
6826
+ };
6487
6827
  };
6488
6828
  const linkFolder = async (p, endpoint, dir, attach) => {
6489
6829
  let onboarding = null;
@@ -6515,12 +6855,17 @@ async function main(argv) {
6515
6855
  `);
6516
6856
  return 0;
6517
6857
  }
6858
+ if (flags["help"] !== true && GIT_COMMANDS.has(cmd[0] ?? "")) {
6859
+ const line = replacedLine(await ensureTempDir());
6860
+ if (line !== null) process.stderr.write(line + "\n");
6861
+ }
6518
6862
  switch (flags["help"] === true ? "help" : cmd[0]) {
6519
6863
  case void 0:
6520
6864
  case "help":
6521
6865
  process.stdout.write(USAGE);
6522
6866
  return 0;
6523
6867
  case "doctor": {
6868
+ const temp = await ensureTempDir().catch((e) => e instanceof PublisherError ? { error: e.message } : null);
6524
6869
  const git = await gitVersion().catch(() => null);
6525
6870
  const dir = dirArg(1);
6526
6871
  const s = credentialStatus(await resolutionInput(dir));
@@ -6542,6 +6887,7 @@ async function main(argv) {
6542
6887
  profiles: s.profiles,
6543
6888
  defaultProfile: s.defaultProfile,
6544
6889
  credentialsFile: credentialsPath(),
6890
+ tempDir: temp,
6545
6891
  association: association === null ? null : { endpoint: association.endpoint, repoId: association.repoId },
6546
6892
  // Printed as it comes: `moved` carries where to, `unreachable` carries why, and the two
6547
6893
  // need different next steps so they are not collapsed into "not ok".
@@ -6561,48 +6907,8 @@ async function main(argv) {
6561
6907
  out({ ...result, next: { action: "reauthenticate", ask: null, command } });
6562
6908
  return 0;
6563
6909
  }
6564
- case "login": {
6565
- const portal = portalBase(process.env, str2(flags["portal"]));
6566
- const input = await resolutionInput();
6567
- const target = resolveLoginEndpoint(input);
6568
- const endpoint = "endpoint" in target ? target.endpoint : await discoverEndpoint(portal);
6569
- if (!endpoint) throw new PublisherError("INVALID_REQUEST", "login requires --endpoint (or --profile naming a stored one)");
6570
- const given = str2(flags["token"]) ?? process.env["GIT_PUBLISHER_TOKEN"];
6571
- const signIn = given ? null : await deviceLogin({
6572
- endpoint,
6573
- onPrompt: ({ userCode, verificationUri, expiresInSeconds }) => {
6574
- process.stderr.write(
6575
- `
6576
- To sign in, open:
6577
- ${verificationUri}
6578
-
6579
- and confirm the code:
6580
- ${userCode}
6581
-
6582
- Waiting (it expires in ${Math.round(expiresInSeconds / 60)} minutes)...
6583
- `
6584
- );
6585
- }
6586
- });
6587
- const token = given ?? signIn.token;
6588
- const p = createPublisher({ endpoint, token });
6589
- const caps = await verifiedCapabilities(p);
6590
- let profileFlag = str2(flags["profile"]);
6591
- if (!profileFlag && isInteractive({ json, isTTY: process.stdin.isTTY })) {
6592
- const suggested = suggestProfileName(endpoint, new Set(Object.keys(input.file.profiles)));
6593
- profileFlag = await askProfileName(endpoint, suggested) ?? void 0;
6594
- }
6595
- const path = await saveCredential(endpoint, token, void 0, {
6596
- ...profileFlag ? { profile: profileFlag } : {},
6597
- setDefault: flags["set-default"] === true,
6598
- // The refresh half, when the service issued one: the sign-in outlives the credential.
6599
- ...signIn ? { signIn } : {}
6600
- });
6601
- const file = await readCredentials();
6602
- const profile = profileForEndpoint(file, endpoint, profileFlag);
6603
- out({ ok: true, endpoint, profile, isDefault: profile !== null && file.defaultProfile === profile, server: caps.server, credentialsFile: path });
6604
- return 0;
6605
- }
6910
+ case "login":
6911
+ return login({ flags, json, out, log, input: await resolutionInput() });
6606
6912
  case "logout": {
6607
6913
  const profile = str2(flags["profile"]);
6608
6914
  const endpoint = str2(flags["endpoint"]) ?? process.env["GIT_PUBLISHER_ENDPOINT"];
@@ -6740,11 +7046,20 @@ Waiting (it expires in ${Math.round(expiresInSeconds / 60)} minutes)...
6740
7046
  case "sync": {
6741
7047
  const dir = dirArg(1);
6742
7048
  const timeoutMs = followTimeoutMs(str2(flags["timeout"]));
6743
- const { p, endpoint } = await client(dir);
7049
+ const clock = stopwatch();
7050
+ const { p, endpoint, source } = await client(dir);
7051
+ clock.lap("discovery");
7052
+ await resolveToken(endpoint, str2(flags["token"]), { endpointSource: source });
7053
+ clock.lap("authentication");
6744
7054
  const linked = await isGitRepo(dir) && await readAssociation(dir) !== null ? null : await linkFolder(p, endpoint, dir, {});
6745
- const r = await p.syncChanges({ directory: dir, markConflicts: flags["mark-conflicts"] === true, ...optStr(flags, "message") }, { onProgress: progress });
7055
+ clock.lap("link", linked !== null);
7056
+ const sending = splitAtBegin(clock, progress);
7057
+ const r = await p.syncChanges({ directory: dir, markConflicts: flags["mark-conflicts"] === true, ...optStr(flags, "message") }, { onProgress: sending.onProgress });
7058
+ const acceptedAt = sending.done();
6746
7059
  const o = r.published;
6747
7060
  const followed = o !== null && o.state === "accepted" && flags["no-wait"] !== true ? await followPublication(p, endpoint, o, { ...following, timeoutMs, dir }) : null;
7061
+ const syncTimings = clock.read(followed !== null && "stages" in followed ? runTimings(followed.stages, acceptedAt) : {});
7062
+ log(timingsLine(syncTimings));
6748
7063
  out({
6749
7064
  ok: r.state === "published" || r.state === "up_to_date",
6750
7065
  state: r.state,
@@ -6762,7 +7077,8 @@ Waiting (it expires in ${Math.round(expiresInSeconds / 60)} minutes)...
6762
7077
  ...o.next === null ? {} : { next: o.next }
6763
7078
  },
6764
7079
  hint: syncHint(r.state, r.reconciled.before.conflicts, r.marked),
6765
- progress: followed
7080
+ progress: followed,
7081
+ timings: syncTimings
6766
7082
  });
6767
7083
  return r.state === "published" || r.state === "up_to_date" ? 0 : r.state === "unknown" ? 3 : 2;
6768
7084
  }
@@ -6783,7 +7099,11 @@ Waiting (it expires in ${Math.round(expiresInSeconds / 60)} minutes)...
6783
7099
  if (publication.state !== "accepted") {
6784
7100
  throw new PublisherError("LOCAL_STATE", `publication ${publicationId} is ${publication.state}, not accepted; run publisher status`);
6785
7101
  }
6786
- out({ publicationId, progress: await followPublication(p, endpoint, publication, { ...following, timeoutMs, dir }) });
7102
+ const clock = stopwatch();
7103
+ const followed = await followPublication(p, endpoint, publication, { ...following, timeoutMs, dir });
7104
+ const timings = clock.read("stages" in followed ? runTimings(followed.stages, null) : {});
7105
+ log(timingsLine(timings));
7106
+ out({ publicationId, progress: followed, timings });
6787
7107
  return 0;
6788
7108
  }
6789
7109
  /**
@@ -6800,7 +7120,7 @@ Waiting (it expires in ${Math.round(expiresInSeconds / 60)} minutes)...
6800
7120
  }
6801
7121
  /** Answer it, as `question=value` pairs, re-reading the preparation first. */
6802
7122
  case "answer": {
6803
- const dir = (0, import_node_path8.resolve)(".");
7123
+ const dir = (0, import_node_path9.resolve)(".");
6804
7124
  const { p, endpoint } = await client(dir);
6805
7125
  const repoId = str2(flags["repo"]) ?? (await readAssociation(dir))?.repoId;
6806
7126
  if (!repoId) throw new PublisherError("LOCAL_STATE", "no repository association; pass --repo");
@@ -6843,7 +7163,8 @@ ${USAGE}`);
6843
7163
  return 1;
6844
7164
  }
6845
7165
  }
6846
- function demand(r, input) {
7166
+ var GIT_COMMANDS = /* @__PURE__ */ new Set(["init", "publish", "status", "fetch", "checkout", "workspace-status", "sync", "merge-check"]);
7167
+ async function demand(r, input) {
6847
7168
  if (r.kind === "resolved") return r;
6848
7169
  const next = nextStep(credentialStatus(input));
6849
7170
  const details = { profiles: r.kind === "none" ? [] : r.profiles, next };
@@ -6853,8 +7174,10 @@ function demand(r, input) {
6853
7174
  throw new PublisherError("LOCAL_STATE", `no such profile: ${r.name}.${run}`, { details });
6854
7175
  case "ambiguous":
6855
7176
  throw new PublisherError("LOCAL_STATE", `several connections and none is the default: pass --profile <name>, or choose one once.${run}`, { details });
6856
- case "none":
6857
- throw new PublisherError("LOCAL_STATE", `not connected to a publishing service yet.${run}`, { details });
7177
+ case "none": {
7178
+ const where = await whereCredentialsWere();
7179
+ throw new PublisherError("LOCAL_STATE", `not connected to a publishing service yet: ${where.sentence}${run}`, { details: { ...details, credentialsFile: where.details } });
7180
+ }
6858
7181
  }
6859
7182
  }
6860
7183
  function syncHint(state, conflicts, marked) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@minnovate/shadow-publisher",
3
- "version": "0.10.3",
3
+ "version": "0.11.1",
4
4
  "description": "publisher: publish an app's Git history to the serverless Git backend",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",