@minnovate/shadow-publisher 0.3.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -4,11 +4,18 @@ Publish an app's Git history to the serverless Git publishing backend.
4
4
 
5
5
  ```sh
6
6
  npm install -g @minnovate/shadow-publisher
7
- publisher --help
8
- publisher login
7
+ publisher skills install # adds the publisher's skills to Claude Code
9
8
  ```
10
9
 
11
- Needs Node 22.12 or later and git 2.46 or later on `PATH`. The package is one bundled file with no
12
- runtime dependencies.
10
+ Then ask Claude Code to publish your app. Without Claude Code:
11
+
12
+ ```sh
13
+ publisher login # signs you in through your browser
14
+ publisher init . --name my-app
15
+ publisher sync # after every change: saves it and publishes it; no git needed
16
+ ```
17
+
18
+ Needs Node 22.12 or later and git 2.46 or later on `PATH`. The package is one bundled file, and the
19
+ Claude Code skills beside it, with no runtime dependencies.
13
20
 
14
21
  Apache-2.0 licensed; see `LICENSE`.
@@ -0,0 +1,12 @@
1
+ {
2
+ "name": "shadow-publisher",
3
+ "owner": { "name": "Minnovate" },
4
+ "description": "The publisher's Claude Code skills: publish an app and keep it in sync, without git.",
5
+ "plugins": [
6
+ {
7
+ "name": "git-publisher",
8
+ "source": "./plugin",
9
+ "description": "Publish an app folder and keep it updated (\"publish my app\", \"save my changes\", \"get the latest version\")."
10
+ }
11
+ ]
12
+ }
@@ -0,0 +1,8 @@
1
+ {
2
+ "name": "git-publisher",
3
+ "displayName": "Git Publisher",
4
+ "version": "0.5.0",
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
+ "author": { "name": "git-publisher project" },
7
+ "keywords": ["git", "publish", "deploy", "serverless"]
8
+ }
@@ -0,0 +1,16 @@
1
+ # Git Publisher plugin for Claude Code
2
+
3
+ Thin adapter: two skills (`publish-app`, `sync-app`) that drive the `publisher` CLI
4
+ (`@minnovate/shadow-publisher`, `packages/cli`) from PATH. All publication, retry, resume and merge
5
+ logic lives in the shared client library; the skills only orchestrate, and a creator's changes are
6
+ saved and published with `publisher sync`, never with git.
7
+
8
+ It ships inside the npm package, and `publisher skills install` installs it into Claude Code.
9
+
10
+ Runtime requirements in the environment where Claude Code runs: the publisher installed
11
+ (`npm install -g @minnovate/shadow-publisher`, Node.js 22), Git ≥ 2.46 on PATH, outbound HTTPS to
12
+ the portal's domain (sign-in and publishing at `/publish`) and the S3 bucket host for direct
13
+ uploads/downloads.
14
+
15
+ See `integrations/claude/README.md` for installation, authentication, uninstall and the verified
16
+ environments.
@@ -0,0 +1,124 @@
1
+ ---
2
+ name: publish-app
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(git status *), Bash(git diff *), Bash(git log *)
5
+ ---
6
+
7
+ # Publish or update the user's app
8
+
9
+ You publish the user's app folder with the `publisher` command. All saving, merging, publishing,
10
+ retry and resume logic lives in it; your job is to run it, read its `--json` output, and tell the
11
+ user what happened in plain language. Never explain S3, packs, commits, sessions or tokens.
12
+
13
+ **`publisher sync` is how a change is saved and published. Never run `git add`, `git commit`,
14
+ `git push` or `git stash` in the user's app folder**, and never add a git remote. `sync` records the
15
+ folder's changes itself — and leaves out `.env` files, keys, `node_modules` and anything larger than
16
+ 50 MB, which a hand-made commit would not. You may read with `git status`, `git diff` and `git log`
17
+ to describe a change; nothing else.
18
+
19
+ Every command accepts `--json` and prints exactly one JSON object on stdout. Exit codes:
20
+ `0` success · `1` local/usage error · `2` rejected or conflict · `3` outcome not yet known · `4` authentication.
21
+
22
+ ## 1. Preflight (run once)
23
+
24
+ ```bash
25
+ publisher --version
26
+ ```
27
+
28
+ If the command is not found, tell the user the publisher is not installed and that one command
29
+ installs it: `npm install -g @minnovate/shadow-publisher` (it needs Node.js 22). Stop there.
30
+
31
+ ```bash
32
+ publisher doctor --json
33
+ ```
34
+
35
+ - `gitOk:false` → tell the user Git 2.46 or later must be installed; stop.
36
+ - Otherwise do exactly what **`next.action`** says. `doctor` works this out for you, and the same
37
+ `next` object rides on `error.details` of any command that cannot resolve a connection — so you
38
+ never have to interpret `credentials`, `endpoint` or `profiles` yourself.
39
+
40
+ | `next.action` | What it means | What to do |
41
+ |---|---|---|
42
+ | `"ready"` | Signed in and pointed somewhere. | **Nothing. Go to step 2 and do not mention any of this.** |
43
+ | `"sign-in"` | Not connected to anything yet. | **Ask them nothing** — `next.ask` is `null`. Just run `next.command` (`publisher login --json`). It finds the service, opens their browser and prints a code for them to confirm; tell them to expect that, and wait. Use `--portal <url>` only if they say they are on a different platform. |
44
+ | `"choose-profile"` | Several places are set up and none is marked usual. | Show `next.profiles` and ask `next.ask`. Then run `next.command` with the name they picked. |
45
+ | `"reauthenticate"` | The place is known; the credential is gone or expired. | Just run `next.command`. `next.ask` is `null` — do **not** ask them for anything; the browser sign-in asks them itself. |
46
+ | `"bind-endpoint"` | A token is set in the environment, but nothing said where it may go. | **Stop** and tell them publishing is blocked by a token set in this shell. `next.command` is `null` on purpose: never set `GIT_PUBLISHER_ENDPOINT` or pass `--endpoint` yourself — that is exactly the choice the check leaves to whoever set the token. |
47
+
48
+ **Never ask the user for a token, an endpoint, a URL or a credential — in any words.** They are
49
+ publishing an app; they do not know what those are, and you no longer need them: `login` discovers
50
+ where publishing lives and signs them in through their browser. `next.ask` is `null` whenever
51
+ nothing needs asking, which is now every case except choosing between connections they already
52
+ have; when it is not null it is already phrased for them, so pass it on rather than rewriting it.
53
+
54
+ If they name an environment ("publish to dev"), pass `--profile dev` on each command rather than
55
+ changing their default.
56
+
57
+ ## 2. First publication of a folder
58
+
59
+ Run in the app folder (the folder the user is working in):
60
+
61
+ ```bash
62
+ publisher info --json # exit 1 with LOCAL_STATE "not associated" means: not published yet
63
+ ```
64
+
65
+ If not associated:
66
+
67
+ ```bash
68
+ publisher init . --name "<app name>" --json
69
+ ```
70
+
71
+ `init` prepares the folder with an explicit file selection: it excludes `.git`, `node_modules`,
72
+ build caches, `.env*`, keys/certificates and files larger than 50 MB, and it respects
73
+ `.gitignore`. Show the user the `onboarding.excluded` list so they know what was left out (the
74
+ exclusion rules are a safety net, not a secret scanner). Then publish it with step 3.
75
+
76
+ ## 3. Every update — and the first publish after `init`
77
+
78
+ ```bash
79
+ publisher sync . --message "<a few words on what changed>" --json
80
+ ```
81
+
82
+ That one command records what changed in the folder, brings in anything published meanwhile (a
83
+ fast-forward or a clean merge — it never rewrites the user's work), and publishes the result. Write
84
+ the message in the user's words ("Add a contact page"), not git's.
85
+
86
+ ## 4. Reading the result
87
+
88
+ `state` says what happened; `hint` is one sentence you may pass on as it is.
89
+
90
+ - `"published"` → the app was published. Mention what was recorded (`recorded.committed`) and, if
91
+ `recorded.excluded` is not empty, what was left out and why. Then tell them the portal shows the
92
+ app moving through **received → checked → going live → live**, and that it appears there within
93
+ a few minutes. (The publisher cannot follow those steps from here yet.)
94
+ - `"up_to_date"` → nothing new to publish; the service already has this version.
95
+ - `"conflict"` → the user's change and a published change touched the same lines of
96
+ `conflicts[]`. **Nothing was published and nothing was lost.** Do not resolve it on your own and
97
+ never publish with `--force`. Follow the `sync-app` skill's conflict section.
98
+ - `"rejected"` with `publication.rejection.code`:
99
+ - `NON_FAST_FORWARD` / `STALE_GENERATION` / `EXPECTED_OLD_MISMATCH`: somebody published in the
100
+ moment between the merge and the publish. Run `publisher sync . --json` once more
101
+ (`publication.next.command` says the same).
102
+ - `REF_FORBIDDEN`: the user may not publish this branch; tell them which branch and stop.
103
+ - `MISSING_OBJECTS`: the local copy is incomplete (for example a shallow clone). Tell the user to
104
+ publish from a complete copy: `publisher checkout <new folder> --repo <id>`.
105
+ - `LIMIT_EXCEEDED`, `PACK_INVALID`: quote the message; suggest removing very large files from the
106
+ folder if the message mentions size.
107
+ - `"unknown"` / exit code 3: the publication was sent but its outcome is not known yet. Run
108
+ `publisher status . --json`; it waits for and resolves interrupted publications without creating
109
+ duplicates. Repeat once if it still says `finalizing`.
110
+ - Exit code 4: run `publisher doctor --json` and follow `next.action` (normally `reauthenticate`:
111
+ run `next.command`). Never ask the user for a token.
112
+
113
+ ## 5. Interrupted earlier?
114
+
115
+ If a previous publish was interrupted (network loss, closed session), `publisher status . --json`
116
+ resumes or resolves it. Always run it before starting a new sync when the last result was unknown.
117
+
118
+ ## Rules
119
+
120
+ - `publisher sync` is the only way a change is saved and published. Never `git add`, `git commit`,
121
+ `git push`, `git stash`, `git reset`, `git rebase`, and never modify remotes.
122
+ - Never print tokens or signed URLs. The publisher already redacts them.
123
+ - Never publish with `--force` unless the user explicitly asks to overwrite the published history
124
+ and understands the other side's changes will be discarded.
@@ -0,0 +1,70 @@
1
+ ---
2
+ name: sync-app
3
+ description: Bring the published (possibly engineered) version of the user's app into their folder, together with their own changes, and publish the result. Use when the user says "get the latest version", "sync my app", "sync with the engineering changes", when a publish was refused because somebody else published first, or when a sync reported a conflict. Never overwrites local work and never uses git to commit.
4
+ allowed-tools: Bash(publisher *), Bash(git status *), Bash(git log *), Bash(git diff *)
5
+ ---
6
+
7
+ # Keep the user's app in step with what is published
8
+
9
+ **`publisher sync` does all of it**: it records the user's changes, brings in what was published
10
+ meanwhile (a fast-forward, or a clean merge that never rewrites their work), and publishes the
11
+ result. Never run `git add`, `git commit`, `git push`, `git pull`, `git merge`, `git stash`,
12
+ `git reset` or `git rebase` in the user's app folder.
13
+
14
+ ```bash
15
+ publisher sync . --json
16
+ ```
17
+
18
+ Read `state` exactly as the `publish-app` skill describes (step 4). `merged` says whether anything
19
+ published elsewhere was brought in (`"fast-forward"` or `"merge"`); mention it when it is not null.
20
+
21
+ ## Just looking
22
+
23
+ To say where the folder stands without changing anything:
24
+
25
+ ```bash
26
+ publisher workspace-status . --json # state + hint; exit 2 means conflict
27
+ ```
28
+
29
+ | state | meaning | what to do |
30
+ | --- | --- | --- |
31
+ | `up_to_date` | identical to the published version | nothing |
32
+ | `local_changes` | the user changed something the service does not have yet | `publisher sync` |
33
+ | `remote_ahead` | something was published elsewhere; the folder is behind | `publisher sync` |
34
+ | `diverged_clean_merge` | both changed, and the changes combine cleanly | `publisher sync` |
35
+ | `conflict` | both changed the same lines (`conflicts[]`) | see below |
36
+ | `detached` | the folder is not on the branch it publishes | ask the user before anything else |
37
+
38
+ `pending[]` lists interrupted publications; run `publisher status . --json` first when it is not empty.
39
+
40
+ ## A fresh copy
41
+
42
+ When the user wants their app in a new folder (a new computer, a clean start):
43
+
44
+ ```bash
45
+ publisher checkout <new-folder> --repo <repoId> --json # a NEW folder only; refuses a non-empty one
46
+ ```
47
+
48
+ ## Conflicts
49
+
50
+ A `conflict` means the user's version and the published one both changed the same lines of the
51
+ files in `conflicts[]`. **Nothing was published and nothing was lost**: the user's change is
52
+ recorded locally, and the published version is still on the service.
53
+
54
+ 1. List the files and say, in plain words, what each side changed there (`git diff` and `git log`
55
+ may help you describe it).
56
+ 2. Ask the user which version to keep for each file, or whether to combine them. Do not decide for
57
+ them and never publish with `--force` — that discards the other side's work.
58
+ 3. Only after they decide, put both versions into the files, marked:
59
+
60
+ ```bash
61
+ publisher sync . --mark-conflicts --json
62
+ ```
63
+
64
+ Each file in `conflicts[]` now holds both versions between `<<<<<<<`, `=======` and `>>>>>>>`
65
+ lines. Edit each to what they chose, and remove those lines.
66
+ 4. Run `publisher sync . --json`. It records the edit and publishes it. If a file still carries the
67
+ marker lines it refuses, names the files (`error.details.marked`) and publishes nothing; finish
68
+ the edit and run it again.
69
+
70
+ No git command is needed at any point, including here.
@@ -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_path7 = require("node:path");
31
+ var import_node_path8 = require("node:path");
32
32
 
33
33
  // ../protocol/src/versions.ts
34
34
  var PROTOCOL_VERSION = 1;
@@ -44,6 +44,7 @@ var PUBLICATION_ID_RE = new RegExp(`^pub_${ULID_BODY}$`);
44
44
  var CREATION_ID_RE = new RegExp(`^${ULID_BODY}$`);
45
45
  var TRANSACTION_ID_RE = new RegExp(`^(pub|cmp)_${ULID_BODY}$`);
46
46
  var PRINCIPAL_ID_RE = /^[a-zA-Z0-9][a-zA-Z0-9._@-]{0,127}$/;
47
+ var ORG_ID_RE = /^[A-Za-z0-9_-]{1,128}$/;
47
48
  var SHA256_HEX_RE = /^[0-9a-f]{64}$/;
48
49
  function ulid(now = Date.now()) {
49
50
  let time3 = now;
@@ -92,6 +93,10 @@ var ERROR_CODES = [
92
93
  "UNAUTHORIZED",
93
94
  "FORBIDDEN",
94
95
  "REF_FORBIDDEN",
96
+ // The credential names no organisation, so there is nothing to publish a repository FOR. Its own
97
+ // code rather than FORBIDDEN: a caller can act on it (sign in again, choosing an organisation),
98
+ // and a test that could not tell it from "you may not" would prove nothing about the check.
99
+ "ORGANISATION_REQUIRED",
95
100
  // repositories
96
101
  "REPOSITORY_NOT_FOUND",
97
102
  "REPOSITORY_CONFLICT",
@@ -133,6 +138,7 @@ var HTTP_STATUS_FOR_CODE = {
133
138
  UNAUTHORIZED: 401,
134
139
  FORBIDDEN: 403,
135
140
  REF_FORBIDDEN: 403,
141
+ ORGANISATION_REQUIRED: 403,
136
142
  REPOSITORY_NOT_FOUND: 404,
137
143
  REPOSITORY_CONFLICT: 409,
138
144
  REPOSITORY_ARCHIVED: 409,
@@ -2585,6 +2591,7 @@ var PublicationId = string2().check(_regex(PUBLICATION_ID_RE));
2585
2591
  var TransactionId = string2().check(_regex(TRANSACTION_ID_RE));
2586
2592
  var CreationId = string2().check(_regex(CREATION_ID_RE));
2587
2593
  var PrincipalId = string2().check(_regex(PRINCIPAL_ID_RE));
2594
+ var OrgId = string2().check(_regex(ORG_ID_RE));
2588
2595
  var Sha256Hex = string2().check(_regex(SHA256_HEX_RE));
2589
2596
  var Oid = string2().check(_regex(/^[0-9a-f]{40}$|^[0-9a-f]{64}$/));
2590
2597
  var IsoDate = iso_exports.datetime({ offset: true });
@@ -2666,7 +2673,19 @@ var MetaRecord = object({
2666
2673
  objectFormat: ObjectFormat,
2667
2674
  createdAt: IsoDate,
2668
2675
  createdBy: PrincipalId,
2669
- creationId: CreationId
2676
+ creationId: CreationId,
2677
+ /**
2678
+ * The organisation that published this repository, and what `repoId` is derived from: "that
2679
+ * organisation publishes a repository" is the product's sentence.
2680
+ *
2681
+ * `null` is what a record written BEFORE repositories were org-scoped reads as, and every such
2682
+ * record is still readable, cloneable and publishable — `tests/compat/fixtures/schema-v1` is one,
2683
+ * and the promise that it stays readable is why this field is not required. What `null` must
2684
+ * never do is default: the worker refuses to ANNOUNCE a publication it cannot attribute
2685
+ * (`packages/worker/src/publish.ts`), because a consumer that guessed would create an
2686
+ * application in an organisation nobody chose. Every repository created since carries one.
2687
+ */
2688
+ org: _default(nullable(OrgId), null)
2670
2689
  });
2671
2690
  var GrantPattern = string2().check(
2672
2691
  _minLength(1),
@@ -3971,6 +3990,47 @@ async function noteAcceptedPublication(directory, outcome, now = /* @__PURE__ */
3971
3990
  if (!oid) return;
3972
3991
  await advanceWorkspaceBase(directory, { oid, generation: outcome.resultGeneration }, now);
3973
3992
  }
3993
+ async function adoptWorkspace(client, directory, call = {}, now = /* @__PURE__ */ new Date()) {
3994
+ const dir = (0, import_node_path3.resolve)(directory);
3995
+ const existing = await getWorkspaceMapping(dir);
3996
+ if (existing) return existing;
3997
+ const section = await getConfigSection(dir, "publisher").catch(() => null);
3998
+ const a = section ? associationFrom(section) : null;
3999
+ if (!a) throw new PublisherError("LOCAL_STATE", "this folder is not associated with a published repository; run init first");
4000
+ const branch = (await currentBranch(dir))?.replace(/^refs\/heads\//, "") ?? null;
4001
+ if (!branch) throw new PublisherError("LOCAL_STATE", "HEAD is detached; check out the branch this folder publishes, then run sync again");
4002
+ const repo = await client.getRepository(a.repoId, call);
4003
+ const remoteOid = repo.refs[`refs/heads/${branch}`] ?? null;
4004
+ const tip = await revParse(dir, `refs/heads/${branch}`);
4005
+ let baseOid = null;
4006
+ let baseGeneration = 0;
4007
+ if (remoteOid) {
4008
+ if (!await objectExists(dir, remoteOid)) await client.fetch({ repoDir: dir, repoId: a.repoId, remote: a.remote }, call);
4009
+ const haveRemote = await objectExists(dir, remoteOid);
4010
+ if (!tip) {
4011
+ baseOid = haveRemote ? remoteOid : null;
4012
+ baseGeneration = haveRemote ? repo.generation : 0;
4013
+ } else if (haveRemote && (remoteOid === tip || await isAncestor(dir, remoteOid, tip))) {
4014
+ baseOid = remoteOid;
4015
+ baseGeneration = repo.generation;
4016
+ } else if (haveRemote) {
4017
+ const r = await runGit(["merge-base", tip, remoteOid], { cwd: dir, allowFailure: true });
4018
+ const shared = r.code === 0 ? r.stdout.toString("utf8").trim() : "";
4019
+ baseOid = shared === "" ? null : shared;
4020
+ }
4021
+ }
4022
+ await writeMapping(dir, {
4023
+ endpoint: a.endpoint,
4024
+ repoId: a.repoId,
4025
+ remote: a.remote,
4026
+ branch,
4027
+ baseOid,
4028
+ baseGeneration,
4029
+ checkedOutAt: now.toISOString(),
4030
+ baseAdvancedAt: now.toISOString()
4031
+ });
4032
+ return await getWorkspaceMapping(dir);
4033
+ }
3974
4034
  async function isEmptyDir(p) {
3975
4035
  try {
3976
4036
  return (await (0, import_promises4.readdir)(p)).length === 0;
@@ -4065,7 +4125,16 @@ async function inspectWorkspace(client, directory, call = {}) {
4065
4125
  const dir = (0, import_node_path3.resolve)(directory);
4066
4126
  const mapping = await getWorkspaceMapping(dir);
4067
4127
  const pending = await listPending(dir).catch(() => []);
4068
- if (!mapping) return { state: "unassociated", mapping: null, local: null, remote: null, pending, conflicts: [], hint: "not a workspace; run checkout" };
4128
+ if (!mapping)
4129
+ return {
4130
+ state: "unassociated",
4131
+ mapping: null,
4132
+ local: null,
4133
+ remote: null,
4134
+ pending,
4135
+ conflicts: [],
4136
+ hint: "not a workspace yet; run sync here (an associated folder adopts itself), or checkout to create one"
4137
+ };
4069
4138
  const branchRef = `refs/heads/${mapping.branch}`;
4070
4139
  const tip = await revParse(dir, branchRef);
4071
4140
  const head = await revParse(dir, "HEAD");
@@ -4088,27 +4157,31 @@ async function inspectWorkspace(client, directory, call = {}) {
4088
4157
  );
4089
4158
  }
4090
4159
  if (!remoteOid) {
4091
- if (!tip) return done(hasLocalWork ? "local_changes" : "up_to_date", hasLocalWork ? `${work}; commit, then publish` : "empty on both sides");
4092
- return done("local_changes", "the server has no such branch yet; publish to create it");
4160
+ if (!tip) return done(hasLocalWork ? "local_changes" : "up_to_date", hasLocalWork ? `${work}; publisher sync records and publishes them` : "empty on both sides");
4161
+ return done("local_changes", "the server has no such branch yet; publisher sync creates it");
4093
4162
  }
4094
4163
  if (!tip) return done("remote_ahead", hasLocalWork ? `local branch is unborn and there are ${work}; move them aside, then sync` : "local branch is unborn; sync to fast-forward");
4095
4164
  if (!await objectExists(dir, remoteOid)) {
4096
4165
  await client.fetch({ repoDir: dir, repoId: mapping.repoId, remote: mapping.remote }, call);
4097
4166
  if (!await objectExists(dir, remoteOid)) return done("remote_ahead", "server tip could not be fetched; retry");
4098
4167
  }
4099
- if (remoteOid === tip) return done(hasLocalWork ? "local_changes" : "up_to_date", hasLocalWork ? `${work}; commit, then publish` : "in sync with the server");
4168
+ if (remoteOid === tip)
4169
+ return done(hasLocalWork ? "local_changes" : "up_to_date", hasLocalWork ? `${work}; publisher sync records and publishes them` : "in sync with the server");
4100
4170
  if (await isAncestor(dir, remoteOid, tip))
4101
- return done("local_changes", hasLocalWork ? `local commits and ${work}; commit, then publish` : "local commits not yet published; publish");
4171
+ return done(
4172
+ "local_changes",
4173
+ hasLocalWork ? `local commits and ${work}; publisher sync records and publishes them` : "local commits not yet published; publisher sync publishes them"
4174
+ );
4102
4175
  if (await isAncestor(dir, tip, remoteOid))
4103
4176
  return done(
4104
4177
  "remote_ahead",
4105
- hasLocalWork ? `server moved ahead and there are ${work}; commit or stash them, then sync to fast-forward` : "server moved ahead; sync to fast-forward"
4178
+ hasLocalWork ? `server moved ahead and there are ${work}; publisher sync records them, brings the server's changes in and publishes` : "server moved ahead; publisher sync brings it in"
4106
4179
  );
4107
4180
  const merge2 = await mergeRemote({ repoDir: dir, ours: branchRef, theirs: remoteOid, apply: false });
4108
4181
  if (merge2.ok)
4109
4182
  return done(
4110
4183
  "diverged_clean_merge",
4111
- hasLocalWork ? `both sides changed, merge is clean; commit or stash the ${work}, then sync --apply` : "both sides changed, merge is clean; sync --apply, then publish"
4184
+ hasLocalWork ? `both sides changed, merge is clean; publisher sync records the ${work}, merges and publishes` : "both sides changed, merge is clean; publisher sync merges and publishes"
4112
4185
  );
4113
4186
  return done(
4114
4187
  "conflict",
@@ -4118,6 +4191,7 @@ async function inspectWorkspace(client, directory, call = {}) {
4118
4191
  }
4119
4192
  async function syncWorkspace(client, opts, call = {}, now = /* @__PURE__ */ new Date()) {
4120
4193
  const dir = (0, import_node_path3.resolve)(opts.directory);
4194
+ await adoptWorkspace(client, dir, call, now);
4121
4195
  const before = await inspectWorkspace(client, dir, call);
4122
4196
  if (!before.mapping) throw new PublisherError("LOCAL_STATE", "not a workspace; run checkout");
4123
4197
  const mapping = before.mapping;
@@ -4125,7 +4199,7 @@ async function syncWorkspace(client, opts, call = {}, now = /* @__PURE__ */ new
4125
4199
  let applied = null;
4126
4200
  if (before.state === "detached") throw new PublisherError("LOCAL_STATE", before.hint);
4127
4201
  if (opts.apply && before.remote?.oid && (before.state === "remote_ahead" || before.state === "diverged_clean_merge")) {
4128
- if (before.local?.dirty) throw new PublisherError("LOCAL_STATE", "the working tree has uncommitted changes; commit or stash them before sync --apply");
4202
+ if (before.local?.dirty) throw new PublisherError("LOCAL_STATE", "the working tree has uncommitted changes; record them first (publisher sync does)");
4129
4203
  if (!before.local?.tip) {
4130
4204
  await runGit(["checkout", "-q", "-B", mapping.branch, before.remote.oid], { cwd: dir });
4131
4205
  applied = "fast-forward";
@@ -4419,17 +4493,205 @@ async function updateEntry(url2, change) {
4419
4493
  await writeCredentials(file);
4420
4494
  }
4421
4495
 
4496
+ // ../client/src/onboard.ts
4497
+ var import_promises6 = require("node:fs/promises");
4498
+ var import_node_path5 = require("node:path");
4499
+ var DEFAULT_EXCLUDED_DIRS = [
4500
+ ".git",
4501
+ "node_modules",
4502
+ ".venv",
4503
+ "venv",
4504
+ "__pycache__",
4505
+ ".cache",
4506
+ ".next",
4507
+ ".nuxt",
4508
+ ".turbo",
4509
+ ".parcel-cache",
4510
+ ".aws",
4511
+ ".ssh",
4512
+ ".gnupg",
4513
+ ".idea",
4514
+ ".terraform",
4515
+ ".pytest_cache",
4516
+ ".mypy_cache",
4517
+ "coverage",
4518
+ ".DS_Store"
4519
+ ];
4520
+ var DEFAULT_EXCLUDED_FILES = [
4521
+ /^\.env(\..*)?$/,
4522
+ /\.(pem|key|p12|pfx|jks|keystore)$/i,
4523
+ /^id_(rsa|dsa|ecdsa|ed25519)(\.pub)?$/,
4524
+ /^\.npmrc$/,
4525
+ /^\.pypirc$/,
4526
+ /^\.netrc$/,
4527
+ /\.(log|tmp|swp)$/i,
4528
+ /^\.DS_Store$/,
4529
+ /^Thumbs\.db$/i,
4530
+ /^credentials(\.json)?$/i,
4531
+ /^service-account.*\.json$/i,
4532
+ /^\.git(modules|attributes)?$/
4533
+ ];
4534
+ async function onboardFolder(opts) {
4535
+ const dir = opts.dir;
4536
+ const policy = opts.policy ?? {};
4537
+ const maxBytes = policy.maxFileBytes ?? 50 * 1024 * 1024;
4538
+ let initialized = false;
4539
+ if (await isGitRepo(dir)) {
4540
+ const head = await headCommit(dir);
4541
+ if (head && !policy.allowExistingHistory) {
4542
+ throw new PublisherError("LOCAL_STATE", "folder is already a Git repository with history; commit your changes and use publish instead of init");
4543
+ }
4544
+ } else {
4545
+ await initWorkRepo(dir, opts.branch ?? "main");
4546
+ initialized = true;
4547
+ }
4548
+ const excluded = [];
4549
+ const candidates = [];
4550
+ const extra = policy.exclude ?? [];
4551
+ const walk = async (d) => {
4552
+ for (const e of await (0, import_promises6.readdir)(d, { withFileTypes: true })) {
4553
+ if (opts.signal?.aborted) throw new PublisherError("CANCELLED", "cancelled");
4554
+ const full = (0, import_node_path5.join)(d, e.name);
4555
+ const rel = (0, import_node_path5.relative)(dir, full).split(import_node_path5.sep).join("/");
4556
+ if (e.isSymbolicLink()) {
4557
+ excluded.push({ path: rel, reason: "symbolic link" });
4558
+ continue;
4559
+ }
4560
+ if (e.isDirectory()) {
4561
+ if (excludedDir(rel, e.name, extra)) {
4562
+ excluded.push({ path: rel + "/", reason: "excluded by policy" });
4563
+ continue;
4564
+ }
4565
+ await walk(full);
4566
+ continue;
4567
+ }
4568
+ if (!e.isFile()) continue;
4569
+ if (excludedFile(rel, e.name, extra)) {
4570
+ excluded.push({ path: rel, reason: "excluded by policy" });
4571
+ continue;
4572
+ }
4573
+ const st = await (0, import_promises6.stat)(full);
4574
+ if (st.size > maxBytes) {
4575
+ excluded.push({ path: rel, reason: `larger than ${maxBytes} bytes` });
4576
+ continue;
4577
+ }
4578
+ candidates.push(rel);
4579
+ }
4580
+ };
4581
+ await walk(dir);
4582
+ const ignored = await checkIgnore(dir, candidates);
4583
+ const selected = candidates.filter((p) => !ignored.has(p)).sort();
4584
+ for (const p of ignored) excluded.push({ path: p, reason: "ignored by .gitignore" });
4585
+ if (selected.length === 0) return { repoDir: dir, commit: null, committed: [], excluded, initialized };
4586
+ const commit = await commitPaths(dir, selected, {
4587
+ message: opts.message ?? "Initial publication",
4588
+ ...opts.authorName ? { authorName: opts.authorName } : {},
4589
+ ...opts.authorEmail ? { authorEmail: opts.authorEmail } : {},
4590
+ ...opts.signal ? { signal: opts.signal } : {}
4591
+ });
4592
+ return { repoDir: dir, commit, committed: selected, excluded, initialized };
4593
+ }
4594
+ function excludedDir(rel, name, extra) {
4595
+ return DEFAULT_EXCLUDED_DIRS.includes(name) || extra.some((p) => matches(rel, name, p));
4596
+ }
4597
+ function excludedFile(rel, name, extra) {
4598
+ return DEFAULT_EXCLUDED_FILES.some((re) => re.test(name)) || extra.some((p) => matches(rel, name, p));
4599
+ }
4600
+ async function recordChanges(opts) {
4601
+ const dir = opts.dir;
4602
+ if (!await isGitRepo(dir)) throw new PublisherError("LOCAL_STATE", "this folder is not published yet; run publisher init first");
4603
+ const maxBytes = opts.policy?.maxFileBytes ?? 50 * 1024 * 1024;
4604
+ const extra = opts.policy?.exclude ?? [];
4605
+ const excluded = [];
4606
+ const selected = [];
4607
+ const entries = await statusPorcelain(dir);
4608
+ if (await mergeInProgress(dir)) {
4609
+ const marked = [];
4610
+ for (const { code, path } of entries) {
4611
+ if (!UNMERGED.has(code)) continue;
4612
+ const text = await (0, import_promises6.readFile)((0, import_node_path5.join)(dir, path), "utf8").catch(() => "");
4613
+ if (/^<{7} /m.test(text) && /^>{7} /m.test(text)) marked.push(path);
4614
+ }
4615
+ if (marked.length)
4616
+ throw new PublisherError(
4617
+ "LOCAL_STATE",
4618
+ `still marked as conflicting: ${marked.join(", ")}; keep one version (or both) in each, remove the <<<<<<< ======= >>>>>>> lines, then sync again`,
4619
+ { details: { marked } }
4620
+ );
4621
+ }
4622
+ for (const { code, path } of entries) {
4623
+ if (code !== "??") {
4624
+ selected.push(path);
4625
+ continue;
4626
+ }
4627
+ const parts = path.split("/");
4628
+ const name = parts[parts.length - 1];
4629
+ const dirs = parts.slice(0, -1);
4630
+ const inExcludedDir = dirs.findIndex((d, i) => excludedDir(dirs.slice(0, i + 1).join("/"), d, extra));
4631
+ if (inExcludedDir >= 0) {
4632
+ excluded.push({ path: dirs.slice(0, inExcludedDir + 1).join("/") + "/", reason: "excluded by policy" });
4633
+ continue;
4634
+ }
4635
+ if (excludedFile(path, name, extra)) {
4636
+ excluded.push({ path, reason: "excluded by policy" });
4637
+ continue;
4638
+ }
4639
+ const st = await (0, import_promises6.lstat)((0, import_node_path5.join)(dir, path));
4640
+ if (st.isSymbolicLink()) {
4641
+ excluded.push({ path, reason: "symbolic link" });
4642
+ continue;
4643
+ }
4644
+ if (st.size > maxBytes) {
4645
+ excluded.push({ path, reason: `larger than ${maxBytes} bytes` });
4646
+ continue;
4647
+ }
4648
+ selected.push(path);
4649
+ }
4650
+ const unique = dedupe(excluded);
4651
+ if (selected.length === 0) return { repoDir: dir, commit: null, committed: [], excluded: unique, initialized: false };
4652
+ const commit = await commitPaths(dir, selected.sort(), {
4653
+ message: opts.message ?? "Update",
4654
+ ...opts.authorName ? { authorName: opts.authorName } : {},
4655
+ ...opts.authorEmail ? { authorEmail: opts.authorEmail } : {},
4656
+ ...opts.signal ? { signal: opts.signal } : {}
4657
+ });
4658
+ return { repoDir: dir, commit, committed: selected, excluded: unique, initialized: false };
4659
+ }
4660
+ var UNMERGED = /* @__PURE__ */ new Set(["DD", "AU", "UD", "UA", "DU", "AA", "UU"]);
4661
+ async function mergeInProgress(dir) {
4662
+ return (await runGit(["rev-parse", "-q", "--verify", "MERGE_HEAD"], { cwd: dir, allowFailure: true })).code === 0;
4663
+ }
4664
+ function dedupe(excluded) {
4665
+ const seen = /* @__PURE__ */ new Set();
4666
+ return excluded.filter((e) => seen.has(e.path) ? false : (seen.add(e.path), true));
4667
+ }
4668
+ function matches(rel, base, pattern) {
4669
+ if (pattern.includes("*")) {
4670
+ const re = new RegExp(
4671
+ "^" + pattern.split("*").map((s) => s.replace(/[.+?^${}()|[\]\\]/g, "\\$&")).join(".*") + "$"
4672
+ );
4673
+ return re.test(base) || re.test(rel);
4674
+ }
4675
+ return base === pattern || rel === pattern || rel.startsWith(pattern.replace(/\/$/, "") + "/");
4676
+ }
4677
+ async function checkIgnore(dir, paths) {
4678
+ if (paths.length === 0) return /* @__PURE__ */ new Set();
4679
+ const r = await runGit(["check-ignore", "--stdin", "-z"], { cwd: dir, input: paths.join("\0") + "\0", allowFailure: true });
4680
+ if (r.code !== 0) return /* @__PURE__ */ new Set();
4681
+ return new Set(r.stdout.toString("utf8").split("\0").filter(Boolean));
4682
+ }
4683
+
4422
4684
  // ../client/src/fetch.ts
4423
4685
  var import_node_fs5 = require("node:fs");
4424
- var import_promises7 = require("node:fs/promises");
4686
+ var import_promises8 = require("node:fs/promises");
4425
4687
  var import_node_os3 = require("node:os");
4426
- var import_node_path5 = require("node:path");
4427
- var import_promises8 = require("node:stream/promises");
4688
+ var import_node_path6 = require("node:path");
4689
+ var import_promises9 = require("node:stream/promises");
4428
4690
  var import_node_stream2 = require("node:stream");
4429
4691
  var import_node_crypto4 = require("node:crypto");
4430
4692
 
4431
4693
  // ../client/src/publish.ts
4432
- var import_promises6 = require("node:fs/promises");
4694
+ var import_promises7 = require("node:fs/promises");
4433
4695
 
4434
4696
  // ../client/src/transport.ts
4435
4697
  function assertCredentialEndpoint(endpoint) {
@@ -4614,7 +4876,7 @@ async function publish(ctx, repoId, remote, opts, call) {
4614
4876
  } catch (e) {
4615
4877
  if (!(e instanceof PublisherError && e.code === "LOCAL_STATE")) throw e;
4616
4878
  }
4617
- if (remaining.length === 0) return toOutcome(outcome, true);
4879
+ if (remaining.length === 0) return toOutcome(outcome, true, pending2.refUpdates);
4618
4880
  return publish(ctx, repoId, remote, opts, call);
4619
4881
  }
4620
4882
  }
@@ -4690,7 +4952,7 @@ async function runPublication(ctx, repoId, remote, repoDir, store2, pending, opt
4690
4952
  }
4691
4953
  if (opts.wait === false) {
4692
4954
  summary = summary ?? await ctx.getStatus(repoId, pending.publicationId, call);
4693
- return toOutcome(summary, resumed);
4955
+ return toOutcome(summary, resumed, pending.refUpdates);
4694
4956
  }
4695
4957
  progress({ phase: "validate", message: "waiting for validation" });
4696
4958
  summary = await waitForOutcome(ctx, repoId, pending.publicationId, opts.waitTimeoutMs ?? 10 * 6e4, call);
@@ -4700,9 +4962,9 @@ async function runPublication(ctx, repoId, remote, repoDir, store2, pending, opt
4700
4962
  progress({ phase: "done", message: `published generation ${summary.resultGeneration}` });
4701
4963
  } else if (summary.state === "rejected") {
4702
4964
  await store2.remove(pending.publicationId);
4703
- if (pending.pack) await (0, import_promises6.rm)(pending.pack.path, { force: true });
4965
+ if (pending.pack) await (0, import_promises7.rm)(pending.pack.path, { force: true });
4704
4966
  }
4705
- return toOutcome(summary, resumed);
4967
+ return toOutcome(summary, resumed, pending.refUpdates);
4706
4968
  }
4707
4969
  async function waitForOutcome(ctx, repoId, publicationId, timeoutMs, call) {
4708
4970
  const deadline = Date.now() + timeoutMs;
@@ -4749,7 +5011,7 @@ async function resolvePending(ctx, repoDir, remote, pending, call, store2) {
4749
5011
  }
4750
5012
  return summary;
4751
5013
  }
4752
- function toOutcome(s, resumed) {
5014
+ function toOutcome(s, resumed, updates) {
4753
5015
  return {
4754
5016
  publicationId: s.publicationId,
4755
5017
  repoId: s.repoId,
@@ -4758,10 +5020,23 @@ function toOutcome(s, resumed) {
4758
5020
  resultGeneration: s.resultGeneration,
4759
5021
  refs: s.refs,
4760
5022
  rejection: s.rejection,
5023
+ next: publishNext(s.rejection, updates[0]?.ref ?? null),
4761
5024
  resumed,
4762
5025
  summary: s
4763
5026
  };
4764
5027
  }
5028
+ var REF_MOVED = /* @__PURE__ */ new Set(["NON_FAST_FORWARD", "EXPECTED_OLD_MISMATCH", "STALE_GENERATION"]);
5029
+ function publishNext(rejection, published) {
5030
+ if (rejection === null || !REF_MOVED.has(rejection.code)) return null;
5031
+ const named = rejection.details?.["ref"];
5032
+ const ref = typeof named === "string" ? named : published;
5033
+ const name = ref?.replace(/^refs\/heads\//, "") ?? "main";
5034
+ return {
5035
+ action: "sync-and-publish",
5036
+ ask: `${name} moved since your last fetch: someone else published to it. Sync brings those changes in without rewriting yours, and publishes the result.`,
5037
+ command: `publisher sync`
5038
+ };
5039
+ }
4765
5040
  async function defaultUpdate(repoDir) {
4766
5041
  const branch = await currentBranch(repoDir);
4767
5042
  if (!branch) throw new PublisherError("LOCAL_STATE", "detached HEAD: specify which ref to publish");
@@ -4807,15 +5082,15 @@ function trackingRef(remote, ref) {
4807
5082
  // ../client/src/fetch.ts
4808
5083
  async function installedManifest(gd) {
4809
5084
  try {
4810
- const parsed = JSON.parse(await (0, import_promises7.readFile)((0, import_node_path5.join)(gd, "publisher", "installed.json"), "utf8"));
5085
+ const parsed = JSON.parse(await (0, import_promises8.readFile)((0, import_node_path6.join)(gd, "publisher", "installed.json"), "utf8"));
4811
5086
  return new Set(Array.isArray(parsed) ? parsed.filter((x) => typeof x === "string") : []);
4812
5087
  } catch {
4813
5088
  return /* @__PURE__ */ new Set();
4814
5089
  }
4815
5090
  }
4816
5091
  async function writeManifest(gd, installed) {
4817
- await (0, import_promises7.mkdir)((0, import_node_path5.join)(gd, "publisher"), { recursive: true });
4818
- await (0, import_promises7.writeFile)((0, import_node_path5.join)(gd, "publisher", "installed.json"), JSON.stringify(installed));
5092
+ await (0, import_promises8.mkdir)((0, import_node_path6.join)(gd, "publisher"), { recursive: true });
5093
+ await (0, import_promises8.writeFile)((0, import_node_path6.join)(gd, "publisher", "installed.json"), JSON.stringify(installed));
4819
5094
  }
4820
5095
  async function fetchRepository(ctx, repoId, opts, call) {
4821
5096
  const remote = opts.remote ?? "publisher";
@@ -4862,13 +5137,14 @@ async function haveAll(repoDir, refs) {
4862
5137
  async function install(ctx, gd, artifacts, call) {
4863
5138
  if (artifacts.length === 0) return 0;
4864
5139
  const progress = call.onProgress ?? (() => void 0);
4865
- const tmp = await (0, import_promises7.mkdtemp)((0, import_node_path5.join)((0, import_node_os3.tmpdir)(), "gitpub-fetch-"));
5140
+ const tmp = await (0, import_promises8.mkdtemp)((0, import_node_path6.join)((0, import_node_os3.tmpdir)(), "gitpub-fetch-"));
4866
5141
  let bytes = 0;
5142
+ const total = artifacts.reduce((sum, a) => sum + a.size, 0);
4867
5143
  try {
4868
5144
  for (const a of artifacts) {
4869
- const file = (0, import_node_path5.join)(tmp, `${a.sha256}.${a.kind}`);
5145
+ const file = (0, import_node_path6.join)(tmp, `${a.sha256}.${a.kind}`);
4870
5146
  bytes += await download(ctx.fetchImpl, a.url, file, a.sha256, a.size, call);
4871
- progress({ phase: "fetch", message: `installing ${a.kind}`, bytes, total: a.size });
5147
+ progress({ phase: "fetch", message: `installing ${a.kind}`, bytes, total });
4872
5148
  if (a.kind === "bundle") {
4873
5149
  await bundleUnbundleObjects(gd, file);
4874
5150
  } else {
@@ -4876,7 +5152,7 @@ async function install(ctx, gd, artifacts, call) {
4876
5152
  }
4877
5153
  }
4878
5154
  } finally {
4879
- await (0, import_promises7.rm)(tmp, { recursive: true, force: true });
5155
+ await (0, import_promises8.rm)(tmp, { recursive: true, force: true });
4880
5156
  }
4881
5157
  return bytes;
4882
5158
  }
@@ -4916,7 +5192,7 @@ async function download(fetchImpl, url2, dest, sha256, size, call) {
4916
5192
  controller.enqueue(chunk);
4917
5193
  }
4918
5194
  });
4919
- await (0, import_promises8.pipeline)(import_node_stream2.Readable.fromWeb(res.body.pipeThrough(counted)), (0, import_node_fs5.createWriteStream)(dest));
5195
+ await (0, import_promises9.pipeline)(import_node_stream2.Readable.fromWeb(res.body.pipeThrough(counted)), (0, import_node_fs5.createWriteStream)(dest));
4920
5196
  if (n !== size || hash.digest("hex") !== sha256) throw new PublisherError("NETWORK", "downloaded artifact failed checksum verification", { retryable: true });
4921
5197
  return n;
4922
5198
  }
@@ -4924,6 +5200,11 @@ async function download(fetchImpl, url2, dest, sha256, size, call) {
4924
5200
  // ../client/src/publisher.ts
4925
5201
  var CLIENT_NAME = "git-publisher-client";
4926
5202
  var CLIENT_VERSION = "0.1.0";
5203
+ async function markConflicts(dir, remote) {
5204
+ const who = { GIT_AUTHOR_NAME: "Publisher", GIT_AUTHOR_EMAIL: "publisher@localhost", GIT_COMMITTER_NAME: "Publisher", GIT_COMMITTER_EMAIL: "publisher@localhost" };
5205
+ const r = await runGit(["merge", "--no-ff", "--no-commit", "--no-verify", remote], { cwd: dir, env: { extra: who }, allowFailure: true });
5206
+ if (r.code !== 0 && !await mergeInProgress(dir)) throw new PublisherError("GIT", `could not mark the conflicts: ${r.stderr.trim()}`);
5207
+ }
4927
5208
  var Publisher = class {
4928
5209
  constructor(opts) {
4929
5210
  this.opts = opts;
@@ -5022,6 +5303,10 @@ var Publisher = class {
5022
5303
  async checkout(options, call = {}) {
5023
5304
  return checkoutWorkspace(this, options, call);
5024
5305
  }
5306
+ /** Make an associated folder a workspace (idempotent); `init` and `sync` both call it. */
5307
+ async adoptWorkspace(directory, call = {}) {
5308
+ return adoptWorkspace(this, directory, call);
5309
+ }
5025
5310
  /** Offline: the persisted workspace mapping, or null. */
5026
5311
  async workspace(directory) {
5027
5312
  return getWorkspaceMapping(directory);
@@ -5034,6 +5319,37 @@ var Publisher = class {
5034
5319
  async sync(options, call = {}) {
5035
5320
  return syncWorkspace(this, options, call);
5036
5321
  }
5322
+ /**
5323
+ * A creator's whole round trip in one call, so nobody runs git: record their changes, bring in
5324
+ * what the service has (fast-forward or a clean merge, never a rewrite), and publish. It stops,
5325
+ * having published nothing, on a conflict; the recorded commit stays, so nothing is lost.
5326
+ *
5327
+ * Everything that can refuse without touching the folder refuses first: a folder with no
5328
+ * association (or one pinned elsewhere) and a HEAD off the workspace branch would otherwise get
5329
+ * a commit and then an error. `syncWorkspace` itself still never commits or publishes; this is
5330
+ * the composition, and the only one.
5331
+ */
5332
+ async syncChanges(options, call = {}) {
5333
+ const dir = options.directory;
5334
+ await this.resolveRepo(dir);
5335
+ const branch = (await currentBranch(dir))?.replace(/^refs\/heads\//, "") ?? null;
5336
+ if (!branch) throw new PublisherError("LOCAL_STATE", "HEAD is detached; check out the branch this folder publishes, then run sync again");
5337
+ const mapped = await getWorkspaceMapping(dir);
5338
+ if (mapped && mapped.branch !== branch)
5339
+ throw new PublisherError("LOCAL_STATE", `branch ${branch} is checked out, but this folder publishes ${mapped.branch}; check it out, then run sync again`);
5340
+ const recorded = await recordChanges({ dir, ...options.message ? { message: options.message } : {} });
5341
+ const reconciled = await syncWorkspace(this, { directory: dir, apply: true }, call);
5342
+ const base = { recorded: { commit: recorded.commit, committed: recorded.committed, excluded: recorded.excluded }, reconciled };
5343
+ if (reconciled.after === "conflict") {
5344
+ const remote = reconciled.before.remote?.oid ?? null;
5345
+ if (options.markConflicts && remote) await markConflicts(dir, remote);
5346
+ return { ...base, state: "conflict", published: null, marked: options.markConflicts === true && remote !== null };
5347
+ }
5348
+ const tip = await revParse(dir, `refs/heads/${branch}`);
5349
+ if (tip === null || tip === (reconciled.before.remote?.oid ?? null)) return { ...base, state: "up_to_date", published: null, marked: false };
5350
+ const published = await this.publish({ repoDir: dir }, call);
5351
+ return { ...base, state: published.state === "accepted" ? "published" : published.state === "rejected" ? "rejected" : "unknown", published, marked: false };
5352
+ }
5037
5353
  /** Resolve interrupted publications recorded locally without creating duplicates. */
5038
5354
  async resumePublications(repoDir, call = {}) {
5039
5355
  const { remote } = await this.resolveRepo(repoDir);
@@ -5051,7 +5367,7 @@ var Publisher = class {
5051
5367
  call.onProgress?.({ phase: "validate", message: `waiting for ${p.publicationId}` });
5052
5368
  s = await resolvePending(this.publishContext(), repoDir, remote, p, { ...call, waitSeconds: MAX_WAIT_SECONDS }, store2);
5053
5369
  }
5054
- out.push(toOutcome(s, true));
5370
+ out.push(toOutcome(s, true, p.refUpdates));
5055
5371
  }
5056
5372
  for (const o of out) await noteAcceptedPublication(repoDir, o);
5057
5373
  return out;
@@ -5087,120 +5403,6 @@ function createPublisher(opts) {
5087
5403
  return new Publisher(opts);
5088
5404
  }
5089
5405
 
5090
- // ../client/src/onboard.ts
5091
- var import_promises9 = require("node:fs/promises");
5092
- var import_node_path6 = require("node:path");
5093
- var DEFAULT_EXCLUDED_DIRS = [
5094
- ".git",
5095
- "node_modules",
5096
- ".venv",
5097
- "venv",
5098
- "__pycache__",
5099
- ".cache",
5100
- ".next",
5101
- ".nuxt",
5102
- ".turbo",
5103
- ".parcel-cache",
5104
- ".aws",
5105
- ".ssh",
5106
- ".gnupg",
5107
- ".idea",
5108
- ".terraform",
5109
- ".pytest_cache",
5110
- ".mypy_cache",
5111
- "coverage",
5112
- ".DS_Store"
5113
- ];
5114
- var DEFAULT_EXCLUDED_FILES = [
5115
- /^\.env(\..*)?$/,
5116
- /\.(pem|key|p12|pfx|jks|keystore)$/i,
5117
- /^id_(rsa|dsa|ecdsa|ed25519)(\.pub)?$/,
5118
- /^\.npmrc$/,
5119
- /^\.pypirc$/,
5120
- /^\.netrc$/,
5121
- /\.(log|tmp|swp)$/i,
5122
- /^\.DS_Store$/,
5123
- /^Thumbs\.db$/i,
5124
- /^credentials(\.json)?$/i,
5125
- /^service-account.*\.json$/i,
5126
- /^\.git(modules|attributes)?$/
5127
- ];
5128
- async function onboardFolder(opts) {
5129
- const dir = opts.dir;
5130
- const policy = opts.policy ?? {};
5131
- const maxBytes = policy.maxFileBytes ?? 50 * 1024 * 1024;
5132
- let initialized = false;
5133
- if (await isGitRepo(dir)) {
5134
- const head = await headCommit(dir);
5135
- if (head && !policy.allowExistingHistory) {
5136
- throw new PublisherError("LOCAL_STATE", "folder is already a Git repository with history; commit your changes and use publish instead of init");
5137
- }
5138
- } else {
5139
- await initWorkRepo(dir, opts.branch ?? "main");
5140
- initialized = true;
5141
- }
5142
- const excluded = [];
5143
- const candidates = [];
5144
- const extra = policy.exclude ?? [];
5145
- const walk = async (d) => {
5146
- for (const e of await (0, import_promises9.readdir)(d, { withFileTypes: true })) {
5147
- if (opts.signal?.aborted) throw new PublisherError("CANCELLED", "cancelled");
5148
- const full = (0, import_node_path6.join)(d, e.name);
5149
- const rel = (0, import_node_path6.relative)(dir, full).split(import_node_path6.sep).join("/");
5150
- if (e.isSymbolicLink()) {
5151
- excluded.push({ path: rel, reason: "symbolic link" });
5152
- continue;
5153
- }
5154
- if (e.isDirectory()) {
5155
- if (DEFAULT_EXCLUDED_DIRS.includes(e.name) || extra.some((p) => matches(rel, e.name, p))) {
5156
- excluded.push({ path: rel + "/", reason: "excluded by policy" });
5157
- continue;
5158
- }
5159
- await walk(full);
5160
- continue;
5161
- }
5162
- if (!e.isFile()) continue;
5163
- if (DEFAULT_EXCLUDED_FILES.some((re) => re.test(e.name)) || extra.some((p) => matches(rel, e.name, p))) {
5164
- excluded.push({ path: rel, reason: "excluded by policy" });
5165
- continue;
5166
- }
5167
- const st = await (0, import_promises9.stat)(full);
5168
- if (st.size > maxBytes) {
5169
- excluded.push({ path: rel, reason: `larger than ${maxBytes} bytes` });
5170
- continue;
5171
- }
5172
- candidates.push(rel);
5173
- }
5174
- };
5175
- await walk(dir);
5176
- const ignored = await checkIgnore(dir, candidates);
5177
- const selected = candidates.filter((p) => !ignored.has(p)).sort();
5178
- for (const p of ignored) excluded.push({ path: p, reason: "ignored by .gitignore" });
5179
- if (selected.length === 0) return { repoDir: dir, commit: null, committed: [], excluded, initialized };
5180
- const commit = await commitPaths(dir, selected, {
5181
- message: opts.message ?? "Initial publication",
5182
- ...opts.authorName ? { authorName: opts.authorName } : {},
5183
- ...opts.authorEmail ? { authorEmail: opts.authorEmail } : {},
5184
- ...opts.signal ? { signal: opts.signal } : {}
5185
- });
5186
- return { repoDir: dir, commit, committed: selected, excluded, initialized };
5187
- }
5188
- function matches(rel, base, pattern) {
5189
- if (pattern.includes("*")) {
5190
- const re = new RegExp(
5191
- "^" + pattern.split("*").map((s) => s.replace(/[.+?^${}()|[\]\\]/g, "\\$&")).join(".*") + "$"
5192
- );
5193
- return re.test(base) || re.test(rel);
5194
- }
5195
- return base === pattern || rel === pattern || rel.startsWith(pattern.replace(/\/$/, "") + "/");
5196
- }
5197
- async function checkIgnore(dir, paths) {
5198
- if (paths.length === 0) return /* @__PURE__ */ new Set();
5199
- const r = await runGit(["check-ignore", "--stdin", "-z"], { cwd: dir, input: paths.join("\0") + "\0", allowFailure: true });
5200
- if (r.code !== 0) return /* @__PURE__ */ new Set();
5201
- return new Set(r.stdout.toString("utf8").split("\0").filter(Boolean));
5202
- }
5203
-
5204
5406
  // ../client/src/migrate.ts
5205
5407
  async function fetchCapabilities(endpoint, fetchImpl = fetch, signal) {
5206
5408
  const res = await fetchImpl(`${normalize(endpoint)}/v1/capabilities`, { method: "GET", ...signal ? { signal } : {} });
@@ -5420,7 +5622,9 @@ var OPTIONS = {
5420
5622
  quiet: { type: "boolean" },
5421
5623
  force: { type: "boolean" },
5422
5624
  apply: { type: "boolean" },
5625
+ "mark-conflicts": { type: "boolean" },
5423
5626
  help: { type: "boolean" },
5627
+ version: { type: "boolean" },
5424
5628
  "set-default": { type: "boolean" },
5425
5629
  // A flag of its own, not a negation of --wait: there is no --wait.
5426
5630
  "no-wait": { type: "boolean" }
@@ -5434,10 +5638,67 @@ function parseArgs(argv) {
5434
5638
  }
5435
5639
  }
5436
5640
 
5641
+ // src/skills.ts
5642
+ var import_node_child_process3 = require("node:child_process");
5643
+ var import_node_fs6 = require("node:fs");
5644
+ var import_node_path7 = require("node:path");
5645
+ var MARKETPLACE = "shadow-publisher";
5646
+ var PLUGIN = "git-publisher";
5647
+ function packageRoot() {
5648
+ return (0, import_node_path7.join)((0, import_node_path7.dirname)((0, import_node_fs6.realpathSync)(process.argv[1])), "..");
5649
+ }
5650
+ function packageVersion() {
5651
+ try {
5652
+ return JSON.parse((0, import_node_fs6.readFileSync)((0, import_node_path7.join)(packageRoot(), "package.json"), "utf8")).version;
5653
+ } catch {
5654
+ return "unknown";
5655
+ }
5656
+ }
5657
+ function claude(args) {
5658
+ return new Promise((resolve3) => {
5659
+ let stdout = "";
5660
+ let stderr = "";
5661
+ const child = (0, import_node_child_process3.spawn)("claude", args, { stdio: ["ignore", "pipe", "pipe"] });
5662
+ child.stdout.on("data", (d) => stdout += d.toString("utf8"));
5663
+ child.stderr.on("data", (d) => stderr += d.toString("utf8"));
5664
+ child.on("error", (e) => resolve3({ code: null, stdout, stderr: e.message, missing: e.code === "ENOENT" }));
5665
+ child.on("close", (code) => resolve3({ code, stdout, stderr, missing: false }));
5666
+ });
5667
+ }
5668
+ async function installSkills({ log }) {
5669
+ const source = (0, import_node_path7.join)(packageRoot(), "dist", "claude");
5670
+ if (!(0, import_node_fs6.existsSync)((0, import_node_path7.join)(source, ".claude-plugin", "marketplace.json"))) {
5671
+ throw new PublisherError("LOCAL_STATE", `this publisher carries no skills at ${source}; reinstall it with: npm install -g @minnovate/shadow-publisher`);
5672
+ }
5673
+ const step = async (what, args) => {
5674
+ log(what);
5675
+ const r = await claude(args);
5676
+ if (r.missing) {
5677
+ throw new PublisherError(
5678
+ "LOCAL_STATE",
5679
+ 'Claude Code is not installed, or "claude" is not on PATH. Install it from https://claude.com/claude-code, then run: publisher skills install'
5680
+ );
5681
+ }
5682
+ if (r.code !== 0) throw new PublisherError("LOCAL_STATE", `"claude ${args.slice(0, 3).join(" ")}" failed: ${(r.stderr || r.stdout).trim()}`);
5683
+ };
5684
+ await step("Adding the publisher to Claude Code\u2026", ["plugin", "marketplace", "add", source, "--scope", "user"]);
5685
+ await step("Installing its skills\u2026", ["plugin", "install", `${PLUGIN}@${MARKETPLACE}`, "--scope", "user"]);
5686
+ return {
5687
+ ok: true,
5688
+ plugin: `${PLUGIN}@${MARKETPLACE}`,
5689
+ marketplace: MARKETPLACE,
5690
+ source,
5691
+ version: packageVersion(),
5692
+ message: "The publisher skills are installed in Claude Code for your user. Start a new Claude Code session (or run /reload-plugins in an open one) and ask it to publish your app."
5693
+ };
5694
+ }
5695
+
5437
5696
  // src/main.ts
5438
5697
  var USAGE = `publisher \u2014 publish an app's Git history to the serverless Git backend
5439
5698
 
5440
5699
  Usage:
5700
+ publisher skills install add the publisher's skills to Claude Code (user scope);
5701
+ run it again after updating the publisher
5441
5702
  publisher login sign in. With NO arguments it asks the
5442
5703
  portal where publishing lives, opens a
5443
5704
  browser to confirm who you are, and asks
@@ -5447,19 +5708,29 @@ Usage:
5447
5708
  [--profile <name>] [--set-default] [--token <token>]
5448
5709
  publisher logout (--endpoint <url> | --profile <name>)
5449
5710
  publisher profile list | profile use <name> | profile remove <name>
5450
- publisher init [dir] [--name <name>] [--repo <id>] create (or attach to) a repository and associate this folder
5451
- publisher publish [dir] [--ref <ref>]... [--force] [--no-wait]
5711
+ publisher init [dir] [--name <name>] [--repo <id>] create (or attach to) a repository, associate this folder
5712
+ and record it as a workspace (so sync works here)
5713
+ publisher publish [dir] [--ref <ref>]... [--force] [--no-wait] publish commits as they are; to save and publish
5714
+ a change, use sync
5452
5715
  publisher status [dir] [--publication <id>] resolve pending/interrupted publications
5453
5716
  publisher fetch [dir]
5454
5717
  publisher checkout <dir> --repo <id> [--branch <name>] create a NEW editable workspace (never overlays a folder)
5455
5718
  publisher workspace-status [dir] offline mapping + where the workspace stands (real three-way check)
5456
- publisher sync [dir] [--apply] fetch; with --apply fast-forward or merge cleanly, advance the base
5719
+ publisher sync [dir] [--message <text>] THE way to save and publish a change: records the folder's
5720
+ changes (never .env, keys or node_modules), brings in what
5721
+ was published meanwhile (fast-forward or a clean merge,
5722
+ never a rewrite) and publishes. Stops on a conflict with
5723
+ nothing published and nothing lost. No git needed
5724
+ [--mark-conflicts] on a conflict, put both versions in each file, marked,
5725
+ to edit; the next sync records and publishes the edit
5457
5726
  publisher merge-check [dir] --ours <ref> --theirs <ref> [--apply]
5458
5727
  publisher info [dir] repository state as seen by the server
5459
5728
  publisher doctor environment check (node, git, credentials, association)
5460
5729
  publisher migrate [dir] [--to <url>] re-point a folder whose service moved (asks the service where)
5461
5730
 
5462
- Global flags: --endpoint <url>, --profile <name>, --token, --json, --quiet
5731
+ Global flags: --endpoint <url>, --profile <name>, --token, --json, --quiet, --version
5732
+
5733
+ A creator never needs git: init once, then sync after every change.
5463
5734
 
5464
5735
  Which endpoint a command uses, first hit wins:
5465
5736
  --endpoint <url> an explicit URL; needs no stored profile
@@ -5495,7 +5766,7 @@ async function main(argv) {
5495
5766
  process.stderr.write(`\r[${e.phase}] ${e.message}${pct} `);
5496
5767
  if (e.phase === "done") process.stderr.write("\n");
5497
5768
  };
5498
- const dirArg = (i) => (0, import_node_path7.resolve)(cmd[i] ?? ".");
5769
+ const dirArg = (i) => (0, import_node_path8.resolve)(cmd[i] ?? ".");
5499
5770
  const resolutionInput = async (dir) => {
5500
5771
  let association;
5501
5772
  if (dir && await isGitRepo(dir)) association = (await readAssociation(dir))?.endpoint;
@@ -5524,6 +5795,11 @@ async function main(argv) {
5524
5795
  const token = str2(flags["token"]);
5525
5796
  return { endpoint, p: createPublisher({ endpoint, endpointSource: source, ...token ? { token } : {}, clientName: "git-publisher-cli", clientVersion: CLIENT_VERSION }) };
5526
5797
  };
5798
+ if (flags["version"] === true) {
5799
+ process.stdout.write(`${packageVersion()}
5800
+ `);
5801
+ return 0;
5802
+ }
5527
5803
  switch (flags["help"] === true ? "help" : cmd[0]) {
5528
5804
  case void 0:
5529
5805
  case "help":
@@ -5669,19 +5945,30 @@ Waiting (it expires in ${Math.round(expiresInSeconds / 60)} minutes)...
5669
5945
  await p.getRepository(repoId);
5670
5946
  }
5671
5947
  await p.associate(dir, repoId);
5948
+ const workspace = await p.adoptWorkspace(dir).catch(() => null);
5672
5949
  out({
5673
5950
  ok: true,
5674
5951
  repoId,
5675
5952
  created,
5676
5953
  endpoint,
5677
5954
  dir,
5678
- onboarding: onboarding ? { commit: onboarding.commit, committed: onboarding.committed.length, excluded: onboarding.excluded } : null
5955
+ onboarding: onboarding ? { commit: onboarding.commit, committed: onboarding.committed.length, excluded: onboarding.excluded } : null,
5956
+ workspace: workspace === null ? null : { branch: workspace.branch, baseOid: workspace.baseOid, baseGeneration: workspace.baseGeneration }
5679
5957
  });
5680
5958
  return 0;
5681
5959
  }
5682
5960
  case "publish": {
5683
5961
  let finishPublish2 = function(o) {
5684
- out({ state: o.state, publicationId: o.publicationId, repoId: o.repoId, generation: o.resultGeneration, refs: o.refs, rejection: o.rejection, resumed: o.resumed });
5962
+ out({
5963
+ state: o.state,
5964
+ publicationId: o.publicationId,
5965
+ repoId: o.repoId,
5966
+ generation: o.resultGeneration,
5967
+ refs: o.refs,
5968
+ rejection: o.rejection,
5969
+ resumed: o.resumed,
5970
+ ...o.next === null ? {} : { next: o.next }
5971
+ });
5685
5972
  return o.state === "accepted" ? 0 : o.state === "rejected" ? 2 : 3;
5686
5973
  };
5687
5974
  var finishPublish = finishPublish2;
@@ -5717,7 +6004,13 @@ Waiting (it expires in ${Math.round(expiresInSeconds / 60)} minutes)...
5717
6004
  const resolved = await p.resumePublications(dir, { onProgress: progress });
5718
6005
  out({
5719
6006
  pending: pending.map((x) => x.publicationId),
5720
- resolved: resolved.map((o) => ({ publicationId: o.publicationId, state: o.state, generation: o.resultGeneration, rejection: o.rejection }))
6007
+ resolved: resolved.map((o) => ({
6008
+ publicationId: o.publicationId,
6009
+ state: o.state,
6010
+ generation: o.resultGeneration,
6011
+ rejection: o.rejection,
6012
+ ...o.next === null ? {} : { next: o.next }
6013
+ }))
5721
6014
  });
5722
6015
  return resolved.every((o) => o.state === "accepted") ? 0 : resolved.some((o) => o.state === "rejected") ? 2 : 3;
5723
6016
  }
@@ -5745,12 +6038,40 @@ Waiting (it expires in ${Math.round(expiresInSeconds / 60)} minutes)...
5745
6038
  out(s);
5746
6039
  return s.state === "conflict" ? 2 : 0;
5747
6040
  }
6041
+ /**
6042
+ * The creator's one verb: record the folder's changes, bring in what the service has, publish.
6043
+ * `--apply` is what this was before it recorded and published, and is still accepted, so a
6044
+ * `sync --apply` written down anywhere does the whole round trip rather than failing.
6045
+ */
5748
6046
  case "sync": {
5749
6047
  const dir = dirArg(1);
5750
6048
  const { p } = await client(dir);
5751
- const r = await p.sync({ directory: dir, apply: flags["apply"] === true, ...optStr(flags, "message") }, { onProgress: progress });
5752
- out({ ok: true, before: r.before.state, applied: r.applied, after: r.after, base: { oid: r.mapping.baseOid, generation: r.mapping.baseGeneration }, hint: r.before.hint });
5753
- return r.after === "conflict" ? 2 : 0;
6049
+ const r = await p.syncChanges({ directory: dir, markConflicts: flags["mark-conflicts"] === true, ...optStr(flags, "message") }, { onProgress: progress });
6050
+ const o = r.published;
6051
+ out({
6052
+ ok: r.state === "published" || r.state === "up_to_date",
6053
+ state: r.state,
6054
+ recorded: r.recorded,
6055
+ merged: r.reconciled.applied,
6056
+ conflicts: r.reconciled.before.conflicts,
6057
+ marked: r.marked,
6058
+ publication: o === null ? null : {
6059
+ state: o.state,
6060
+ publicationId: o.publicationId,
6061
+ generation: o.resultGeneration,
6062
+ refs: o.refs,
6063
+ rejection: o.rejection,
6064
+ ...o.next === null ? {} : { next: o.next }
6065
+ },
6066
+ hint: syncHint(r.state, r.reconciled.before.conflicts, r.marked)
6067
+ });
6068
+ return r.state === "published" || r.state === "up_to_date" ? 0 : r.state === "unknown" ? 3 : 2;
6069
+ }
6070
+ case "skills": {
6071
+ if (cmd[1] !== "install") throw new PublisherError("INVALID_REQUEST", `unknown skills command: ${cmd[1] ?? "(none)"}; expected install`);
6072
+ const r = await installSkills({ log });
6073
+ out(r);
6074
+ return 0;
5754
6075
  }
5755
6076
  case "merge-check": {
5756
6077
  const dir = dirArg(1);
@@ -5796,6 +6117,20 @@ function demand(r, input) {
5796
6117
  throw new PublisherError("LOCAL_STATE", `not connected to a publishing service yet.${run}`, { details });
5797
6118
  }
5798
6119
  }
6120
+ function syncHint(state, conflicts, marked) {
6121
+ switch (state) {
6122
+ case "published":
6123
+ return "Your changes are saved and published.";
6124
+ case "up_to_date":
6125
+ return "Nothing new to publish: the service already has this version.";
6126
+ case "conflict":
6127
+ return marked ? `Both versions are now in ${conflicts.join(", ")}, between <<<<<<< and >>>>>>> lines. Edit each to what should be kept, remove those lines, then run publisher sync.` : `Your changes and the published version both changed ${conflicts.join(", ")}. Nothing was published and nothing was lost; decide which version to keep.`;
6128
+ case "rejected":
6129
+ return "The service refused the publication; see publication.rejection.";
6130
+ default:
6131
+ return "The publication was sent but its outcome is not known yet; run publisher status.";
6132
+ }
6133
+ }
5799
6134
  function formatHuman(v) {
5800
6135
  return JSON.stringify(v, null, 2);
5801
6136
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@minnovate/shadow-publisher",
3
- "version": "0.3.0",
3
+ "version": "0.5.0",
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",
@@ -11,7 +11,8 @@
11
11
  "publisher": "./dist/publisher.cjs"
12
12
  },
13
13
  "files": [
14
- "dist/publisher.cjs"
14
+ "dist/publisher.cjs",
15
+ "dist/claude"
15
16
  ],
16
17
  "publishConfig": {
17
18
  "access": "public"
@@ -19,8 +20,8 @@
19
20
  "scripts": {
20
21
  "build": "tsc -b tsconfig.json && pnpm run bundle",
21
22
  "test": "vitest run --project unit --root ../.. packages/cli/",
22
- "bundle": "esbuild src/main.ts --bundle --platform=node --target=node22 --format=cjs --outfile=dist/publisher.cjs --banner:js=\"#!/usr/bin/env node\" --log-level=warning",
23
- "plugin:build": "mkdir -p ../../integrations/claude/plugin/skills/publish-app/bin && cp dist/publisher.cjs ../../integrations/claude/plugin/skills/publish-app/bin/publisher.cjs",
23
+ "bundle": "esbuild src/main.ts --bundle --platform=node --target=node22 --format=cjs --outfile=dist/publisher.cjs --banner:js=\"#!/usr/bin/env node\" --log-level=warning && pnpm run skills",
24
+ "skills": "rm -rf dist/claude && mkdir -p dist/claude && cp -R ../../integrations/claude/.claude-plugin ../../integrations/claude/plugin dist/claude/",
24
25
  "clean": "rm -rf dist *.tsbuildinfo"
25
26
  },
26
27
  "devDependencies": {