@symbols-cli/cli 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +8 -0
- package/README.md +103 -0
- package/dist/auth/client.js +531 -0
- package/dist/auth/credentials.js +293 -0
- package/dist/auth/hosts.js +85 -0
- package/dist/auth/loopback.js +108 -0
- package/dist/auth/pkce.js +33 -0
- package/dist/auth/wire.js +40 -0
- package/dist/commands/arm.js +154 -0
- package/dist/commands/curl.js +101 -0
- package/dist/commands/doctor.js +217 -0
- package/dist/commands/login.js +113 -0
- package/dist/commands/logout.js +78 -0
- package/dist/commands/mcp.js +33 -0
- package/dist/commands/project.js +145 -0
- package/dist/commands/status.js +78 -0
- package/dist/commands/sync.js +94 -0
- package/dist/commands/uninstall.js +149 -0
- package/dist/commands/up.js +176 -0
- package/dist/commands/update.js +120 -0
- package/dist/commands/watch.js +155 -0
- package/dist/commands/whoami.js +103 -0
- package/dist/index.js +147 -0
- package/dist/mcp/scopes.js +215 -0
- package/dist/mcp/server.js +366 -0
- package/dist/mcp/tools.js +646 -0
- package/dist/skills/bundle.js +441 -0
- package/dist/skills/claude-md.js +135 -0
- package/dist/skills/install.js +188 -0
- package/dist/skills/settings-merge.js +107 -0
- package/dist/sync/api.js +380 -0
- package/dist/sync/diff.js +172 -0
- package/dist/sync/ledger.js +319 -0
- package/dist/sync/paths.js +447 -0
- package/dist/sync/protect.js +108 -0
- package/dist/sync/reconcile.js +870 -0
- package/dist/sync/watcher.js +206 -0
- package/dist/util/log.js +58 -0
- package/dist/util/platform.js +79 -0
- package/dist/util/version.js +24 -0
- package/package.json +44 -0
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
// Copyright (c) 2025 Symbols LLC. All rights reserved.
|
|
2
|
+
//
|
|
3
|
+
// This source code is proprietary and confidential. Unauthorized copying,
|
|
4
|
+
// distribution, modification, or use of this file, via any medium, is strictly prohibited.
|
|
5
|
+
// Point Claude Code at the verified bundle — `marketplace add`, then install
|
|
6
|
+
// AND UPDATE, at PROJECT scope.
|
|
7
|
+
//
|
|
8
|
+
// ## `install` is not enough, and that is the bug this whole plan opens with
|
|
9
|
+
//
|
|
10
|
+
// `claude plugin install odin@symbols` on an already-installed plugin answers
|
|
11
|
+
//
|
|
12
|
+
// ✔ Plugin odin@symbols is already installed (scope: user)
|
|
13
|
+
//
|
|
14
|
+
// and EXITS 0 WITHOUT UPGRADING. Only `claude plugin update` moves the cache to
|
|
15
|
+
// a new version directory, and only if `plugin.json`'s `version` changed. This
|
|
16
|
+
// machine is the proof, measured before this file was written:
|
|
17
|
+
//
|
|
18
|
+
// repo plugins/odin-code/.claude-plugin/plugin.json -> 0.7.7
|
|
19
|
+
// claude plugin list --json | odin@symbols .version -> 0.7.4
|
|
20
|
+
//
|
|
21
|
+
// Three releases of skills, none of them installed. So: install (for a machine
|
|
22
|
+
// that has never had it) AND update (for every machine that has), every time.
|
|
23
|
+
// The version half of the contract is enforced at build time by
|
|
24
|
+
// `scripts/publish_plugin_bundle.sh`; this is the install half.
|
|
25
|
+
//
|
|
26
|
+
// ## Why project scope
|
|
27
|
+
//
|
|
28
|
+
// User scope would put Symbols' skills into every repo the user opens. Project
|
|
29
|
+
// scope confines them to `~/Symbols/<Project>`, which is the ownership rule
|
|
30
|
+
// (`util/platform.ts`) applied to Claude Code's own configuration: we bind our
|
|
31
|
+
// plugins to our directories and touch nothing outside them.
|
|
32
|
+
//
|
|
33
|
+
// ⚠ Project scope records `projectPath` in `~/.claude/plugins/installed_plugins.json`
|
|
34
|
+
// as A LITERAL STRING. Renaming or moving the project directory silently detaches
|
|
35
|
+
// the install and skills stop loading, with no error anywhere. `doctor` detects
|
|
36
|
+
// exactly that and calls back into `installPlugins` to repair it.
|
|
37
|
+
import { execFile } from "node:child_process";
|
|
38
|
+
import { promisify } from "node:util";
|
|
39
|
+
import { promises as fs } from "node:fs";
|
|
40
|
+
import { join } from "node:path";
|
|
41
|
+
import { bundleRoot } from "../util/platform.js";
|
|
42
|
+
const exec = promisify(execFile);
|
|
43
|
+
/** The marketplace name `stage_plugins.sh` writes into the staged catalog. */
|
|
44
|
+
export const MARKETPLACE = "symbols";
|
|
45
|
+
export class ClaudeCliMissingError extends Error {
|
|
46
|
+
constructor() {
|
|
47
|
+
super("the `claude` CLI is not on PATH, so Symbols skills cannot be installed. " +
|
|
48
|
+
"Install Claude Code first: https://claude.com/claude-code");
|
|
49
|
+
this.name = "ClaudeCliMissingError";
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
async function claude(args, cwd) {
|
|
53
|
+
try {
|
|
54
|
+
const { stdout, stderr } = await exec("claude", args, { cwd, maxBuffer: 8 * 1024 * 1024 });
|
|
55
|
+
return { code: 0, out: `${stdout}${stderr}` };
|
|
56
|
+
}
|
|
57
|
+
catch (err) {
|
|
58
|
+
const e = err;
|
|
59
|
+
if (e.code === "ENOENT")
|
|
60
|
+
throw new ClaudeCliMissingError();
|
|
61
|
+
return { code: typeof e.code === "number" ? e.code : 1, out: `${e.stdout ?? ""}${e.stderr ?? ""}` };
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* Refuse to `--yes` a marketplace that can run commands.
|
|
66
|
+
*
|
|
67
|
+
* `claude plugin install --yes` accepts "the displayed marketplace-declared
|
|
68
|
+
* command" without a prompt. Ours declares none — it is a path catalog written
|
|
69
|
+
* by `stage_plugins.sh` — and passing `--yes` is only needed because the CLI
|
|
70
|
+
* runs with no TTY. Asserting the catalog is command-free keeps `--yes` from
|
|
71
|
+
* quietly becoming "run whatever the server put in the marketplace file".
|
|
72
|
+
*/
|
|
73
|
+
async function assertMarketplaceIsInert(root) {
|
|
74
|
+
const path = join(root, ".claude-plugin", "marketplace.json");
|
|
75
|
+
const text = await fs.readFile(path, "utf8");
|
|
76
|
+
const catalog = JSON.parse(text);
|
|
77
|
+
const offenders = [];
|
|
78
|
+
const scan = (node, where) => {
|
|
79
|
+
if (!node || typeof node !== "object")
|
|
80
|
+
return;
|
|
81
|
+
for (const [k, v] of Object.entries(node)) {
|
|
82
|
+
if (k === "command" || k === "headersHelper" || k === "installCommand") {
|
|
83
|
+
offenders.push(`${where}.${k}`);
|
|
84
|
+
}
|
|
85
|
+
if (v && typeof v === "object")
|
|
86
|
+
scan(v, `${where}.${k}`);
|
|
87
|
+
}
|
|
88
|
+
};
|
|
89
|
+
scan(catalog, "marketplace");
|
|
90
|
+
for (const [i, p] of (catalog.plugins ?? []).entries())
|
|
91
|
+
scan(p, `plugins[${i}]`);
|
|
92
|
+
if (offenders.length > 0) {
|
|
93
|
+
throw new Error(`refusing to install: the bundle's marketplace.json declares executable fields ` +
|
|
94
|
+
`(${offenders.join(", ")}). A signed bundle is still not a licence to run commands.`);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* Register the bundle as a marketplace, then install AND update every plugin the
|
|
99
|
+
* manifest names, at project scope, with `projectPath` = `projectDir`.
|
|
100
|
+
*/
|
|
101
|
+
export async function installPlugins(projectDir, manifest) {
|
|
102
|
+
const root = bundleRoot();
|
|
103
|
+
await assertMarketplaceIsInert(root);
|
|
104
|
+
// ⚠ `--scope project` IS THE OWNERSHIP RULE, NOT A PREFERENCE.
|
|
105
|
+
//
|
|
106
|
+
// Measured, not assumed: `claude plugin marketplace add <dir>` with no scope
|
|
107
|
+
// answers *"Successfully added marketplace: … (declared in user settings)"* and
|
|
108
|
+
// writes `extraKnownMarketplaces` into **`~/.claude/settings.json`** — the one
|
|
109
|
+
// file `util/platform.ts` says this CLI must never touch. It is the user's, it
|
|
110
|
+
// is shared with every other project they open, and `symbols` has not earned
|
|
111
|
+
// it. `--scope project` (verified present: `claude plugin marketplace add
|
|
112
|
+
// --help` documents `--scope <scope>` as user|project|local) declares it in
|
|
113
|
+
// `<project>/.claude/settings.json` instead, where the same file's merge rules
|
|
114
|
+
// apply and the user can delete it by deleting the project.
|
|
115
|
+
//
|
|
116
|
+
// `add` on an already-known marketplace errors; `update` refreshes it from the
|
|
117
|
+
// source. Try `add`, fall back to `update` — the same order
|
|
118
|
+
// `odin_skill_sync.rs` uses, and for the same reason.
|
|
119
|
+
//
|
|
120
|
+
// ⚠ `marketplace update` takes NO `--scope` (its `--help` lists only `-h`), so
|
|
121
|
+
// it can only refresh a declaration that already exists. That is fine: it runs
|
|
122
|
+
// only after `add` failed, which means one is already declared.
|
|
123
|
+
const added = await claude(["plugin", "marketplace", "add", root, "--scope", "project"], projectDir);
|
|
124
|
+
if (added.code !== 0) {
|
|
125
|
+
const updated = await claude(["plugin", "marketplace", "update", MARKETPLACE], projectDir);
|
|
126
|
+
if (updated.code !== 0) {
|
|
127
|
+
throw new Error(`could not register the Symbols plugin marketplace at ${root}:\n${added.out}\n${updated.out}`);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
const results = [];
|
|
131
|
+
for (const plugin of manifest.plugins) {
|
|
132
|
+
const id = `${plugin.name}@${MARKETPLACE}`;
|
|
133
|
+
// 1. install — a no-op exiting 0 on every machine that already has it.
|
|
134
|
+
const ins = await claude(["plugin", "install", id, "--scope", "project", "--yes"], projectDir);
|
|
135
|
+
// 2. update — THE LOAD-BEARING HALF. This is what moves the cache to the new
|
|
136
|
+
// version directory that Claude Code actually reads.
|
|
137
|
+
const upd = await claude(["plugin", "update", id, "--scope", "project", "--yes"], projectDir);
|
|
138
|
+
if (ins.code !== 0 && upd.code !== 0) {
|
|
139
|
+
// ⚠ NEVER `|| true` HERE. `odin_skill_sync.rs` swallows these failures
|
|
140
|
+
// because a broken claude CLI must not break a user's SHELL; here there is
|
|
141
|
+
// no shell to protect, and a swallowed failure is precisely how 0.7.4
|
|
142
|
+
// survived three releases.
|
|
143
|
+
throw new Error(`failed to install ${id}:\n${ins.out}\n${upd.out}`);
|
|
144
|
+
}
|
|
145
|
+
results.push({
|
|
146
|
+
plugin: id,
|
|
147
|
+
installed: ins.code === 0,
|
|
148
|
+
updated: upd.code === 0,
|
|
149
|
+
detail: `${ins.out.trim()} ${upd.out.trim()}`.trim(),
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
return results;
|
|
153
|
+
}
|
|
154
|
+
/**
|
|
155
|
+
* `claude plugin list --json`, parsed.
|
|
156
|
+
*
|
|
157
|
+
* ⚠ THE ONLY MACHINE-READABLE SOURCE. `claude plugin details` has NO `--json`
|
|
158
|
+
* (verified: `claude plugin details --help` lists no such flag), so asserting
|
|
159
|
+
* against its output would be the substring grep this project's own opening
|
|
160
|
+
* incident argues against — a check that passes on a rendering change and fails
|
|
161
|
+
* on a cosmetic one.
|
|
162
|
+
*/
|
|
163
|
+
export async function listPlugins(cwd) {
|
|
164
|
+
const res = await claude(["plugin", "list", "--json"], cwd);
|
|
165
|
+
if (res.code !== 0)
|
|
166
|
+
throw new Error(`\`claude plugin list --json\` failed:\n${res.out}`);
|
|
167
|
+
let parsed;
|
|
168
|
+
try {
|
|
169
|
+
parsed = JSON.parse(res.out);
|
|
170
|
+
}
|
|
171
|
+
catch {
|
|
172
|
+
throw new Error(`\`claude plugin list --json\` did not return JSON:\n${res.out.slice(0, 400)}`);
|
|
173
|
+
}
|
|
174
|
+
if (!Array.isArray(parsed))
|
|
175
|
+
throw new Error("`claude plugin list --json` did not return an array");
|
|
176
|
+
return parsed;
|
|
177
|
+
}
|
|
178
|
+
/**
|
|
179
|
+
* Count `skills/*` directories under an installed plugin's `installPath`.
|
|
180
|
+
*
|
|
181
|
+
* This is the check that catches a cache which is present, correctly versioned,
|
|
182
|
+
* and MISSING SKILLS — the shape the 0.7.4/0.7.7 incident actually had, where
|
|
183
|
+
* `Skills (22)` was reported against a repo carrying 23.
|
|
184
|
+
*/
|
|
185
|
+
export async function countInstalledSkills(installPath) {
|
|
186
|
+
const entries = await fs.readdir(join(installPath, "skills"), { withFileTypes: true });
|
|
187
|
+
return entries.filter((e) => e.isDirectory()).length;
|
|
188
|
+
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
// Copyright (c) 2025 Symbols LLC. All rights reserved.
|
|
2
|
+
//
|
|
3
|
+
// This source code is proprietary and confidential. Unauthorized copying,
|
|
4
|
+
// distribution, modification, or use of this file, via any medium, is strictly prohibited.
|
|
5
|
+
// `<project>/.claude/settings.json` — MERGED, NEVER OVERWRITTEN.
|
|
6
|
+
//
|
|
7
|
+
// ## The two rules, and why each exists
|
|
8
|
+
//
|
|
9
|
+
// 1. **Never overwrite.** This file is the user's. It can hold hooks, permission
|
|
10
|
+
// rules, model choices and env — none of which we wrote and none of which we
|
|
11
|
+
// can reconstruct. Writing our own document over it destroys work silently
|
|
12
|
+
// and is not recoverable from anything the CLI holds.
|
|
13
|
+
//
|
|
14
|
+
// 2. **If the user disabled a plugin, RESPECT IT.** `enabledPlugins["odin@symbols"]
|
|
15
|
+
// = false` is a decision, and re-enabling it on the next `symbols up` would
|
|
16
|
+
// make the setting untouchable — the user would toggle it off and watch it
|
|
17
|
+
// come back, with no way to win. We add keys that are ABSENT and never change
|
|
18
|
+
// a key that is present.
|
|
19
|
+
//
|
|
20
|
+
// ⚠ This file is inside `~/Symbols/<Project>`, which the CLI owns. It is NOT
|
|
21
|
+
// `~/.claude/settings.json`, which the CLI must never touch — that one is shared
|
|
22
|
+
// with every other project the user works on, and editing it to make our skills
|
|
23
|
+
// load would decide on their behalf that our needs outrank a stranger's repo.
|
|
24
|
+
//
|
|
25
|
+
// ## Why the merge is shallow-by-key rather than deep
|
|
26
|
+
//
|
|
27
|
+
// Only two keys are ours: `extraKnownMarketplaces.symbols` and the
|
|
28
|
+
// `enabledPlugins` entries for the plugins the manifest names. A deep merge
|
|
29
|
+
// would let us reach into `permissions` or `hooks` by accident, and a bug that
|
|
30
|
+
// widens `permissions.allow` is the S2 class arriving through the back door.
|
|
31
|
+
import { promises as fs } from "node:fs";
|
|
32
|
+
import { dirname, join } from "node:path";
|
|
33
|
+
import { bundleRoot } from "../util/platform.js";
|
|
34
|
+
import { MARKETPLACE } from "./install.js";
|
|
35
|
+
export function projectSettingsPath(projectDir) {
|
|
36
|
+
return join(projectDir, ".claude", "settings.json");
|
|
37
|
+
}
|
|
38
|
+
export async function mergeProjectSettings(projectDir, manifest) {
|
|
39
|
+
const path = projectSettingsPath(projectDir);
|
|
40
|
+
let doc = {};
|
|
41
|
+
let existed = false;
|
|
42
|
+
try {
|
|
43
|
+
const raw = await fs.readFile(path, "utf8");
|
|
44
|
+
existed = true;
|
|
45
|
+
const parsed = JSON.parse(raw);
|
|
46
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
47
|
+
// ⚠ FREEZE, never repair. A settings.json we cannot parse is a file the
|
|
48
|
+
// user is mid-edit on, or one another tool owns. Replacing it with a
|
|
49
|
+
// document we can parse is exactly the overwrite this file exists to
|
|
50
|
+
// prevent.
|
|
51
|
+
throw new Error(`${path} is not a JSON object. Refusing to touch it — fix or move it, then re-run.`);
|
|
52
|
+
}
|
|
53
|
+
doc = parsed;
|
|
54
|
+
}
|
|
55
|
+
catch (err) {
|
|
56
|
+
if (err.code !== "ENOENT") {
|
|
57
|
+
if (err instanceof SyntaxError) {
|
|
58
|
+
throw new Error(`${path} is not valid JSON. Refusing to overwrite it: ${err.message}`);
|
|
59
|
+
}
|
|
60
|
+
throw err;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
const added = [];
|
|
64
|
+
const respected = [];
|
|
65
|
+
// ── extraKnownMarketplaces.symbols ─────────────────────────────────────────
|
|
66
|
+
const marketplaces = asObject(doc["extraKnownMarketplaces"]);
|
|
67
|
+
if (marketplaces[MARKETPLACE] === undefined) {
|
|
68
|
+
marketplaces[MARKETPLACE] = { source: { source: "directory", path: bundleRoot() } };
|
|
69
|
+
added.push(`extraKnownMarketplaces.${MARKETPLACE}`);
|
|
70
|
+
}
|
|
71
|
+
else {
|
|
72
|
+
// A user (or this repo's own checkout) may point `symbols` at a git source.
|
|
73
|
+
// Ours is a local directory; theirs wins, and `doctor` reports the
|
|
74
|
+
// divergence rather than this silently retargeting their catalog.
|
|
75
|
+
respected.push(`extraKnownMarketplaces.${MARKETPLACE}`);
|
|
76
|
+
}
|
|
77
|
+
doc["extraKnownMarketplaces"] = marketplaces;
|
|
78
|
+
// ── enabledPlugins ─────────────────────────────────────────────────────────
|
|
79
|
+
const enabled = asObject(doc["enabledPlugins"]);
|
|
80
|
+
for (const plugin of manifest.plugins) {
|
|
81
|
+
const id = `${plugin.name}@${MARKETPLACE}`;
|
|
82
|
+
if (enabled[id] === undefined) {
|
|
83
|
+
enabled[id] = true;
|
|
84
|
+
added.push(`enabledPlugins.${id}`);
|
|
85
|
+
}
|
|
86
|
+
else {
|
|
87
|
+
respected.push(`enabledPlugins.${id}=${String(enabled[id])}`);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
doc["enabledPlugins"] = enabled;
|
|
91
|
+
const changed = added.length > 0 || !existed;
|
|
92
|
+
if (changed)
|
|
93
|
+
await writeJsonAtomic(path, doc);
|
|
94
|
+
return { path, added, respected, changed };
|
|
95
|
+
}
|
|
96
|
+
function asObject(value) {
|
|
97
|
+
return value && typeof value === "object" && !Array.isArray(value)
|
|
98
|
+
? { ...value }
|
|
99
|
+
: {};
|
|
100
|
+
}
|
|
101
|
+
/** Write-then-rename: a crash never leaves the user holding half a settings file. */
|
|
102
|
+
async function writeJsonAtomic(path, doc) {
|
|
103
|
+
await fs.mkdir(dirname(path), { recursive: true, mode: 0o755 });
|
|
104
|
+
const tmp = `${path}.${process.pid}.tmp`;
|
|
105
|
+
await fs.writeFile(tmp, JSON.stringify(doc, null, 2) + "\n", { mode: 0o644 });
|
|
106
|
+
await fs.rename(tmp, path);
|
|
107
|
+
}
|
package/dist/sync/api.js
ADDED
|
@@ -0,0 +1,380 @@
|
|
|
1
|
+
// Copyright (c) 2025 Symbols LLC. All rights reserved.
|
|
2
|
+
//
|
|
3
|
+
// This source code is proprietary and confidential. Unauthorized copying,
|
|
4
|
+
// distribution, modification, or use of this file, via any medium, is strictly prohibited.
|
|
5
|
+
// The wire. Notebooks, project files, cells — typed, and with every refusal
|
|
6
|
+
// classified BEFORE it reaches the sync engine.
|
|
7
|
+
//
|
|
8
|
+
// ## Why the error classes are the important part of this file
|
|
9
|
+
//
|
|
10
|
+
// The engine above decides what to transfer. It must never have to guess what a
|
|
11
|
+
// status code MEANT, because two of them are ambiguous on the wire and guessing
|
|
12
|
+
// either one wrong is data loss:
|
|
13
|
+
//
|
|
14
|
+
// * **404 on a notebook-scoped route** is not "no files". `notebook_is_owned`
|
|
15
|
+
// returns 404 for *deleted*, *never existed*, and *belongs to someone else*
|
|
16
|
+
// (`routes/project_files.rs:165,183,204,233`). A client that reads that as an
|
|
17
|
+
// empty file list computes "every path deleted on the server" and deletes the
|
|
18
|
+
// user's whole project. The >20 circuit breaker does NOT save it — the plan
|
|
19
|
+
// says so explicitly, because the breaker sees a legitimate-looking mass
|
|
20
|
+
// delete. That is guard F4, and it starts HERE with `NotebookGoneError`.
|
|
21
|
+
//
|
|
22
|
+
// * **409 has two meanings on this surface**, and they demand opposite
|
|
23
|
+
// responses: the CAS mismatch (re-diff and probably conflict) and the
|
|
24
|
+
// regime/widget protection refusal (restore the local file, freeze the path).
|
|
25
|
+
// They are NOT distinguished by message text — that would be a substring
|
|
26
|
+
// grep over prose. They are distinguished STRUCTURALLY:
|
|
27
|
+
//
|
|
28
|
+
// - `PATCH /api/notebooks/files/{id}` runs the regime check ONLY when the
|
|
29
|
+
// request renames the file (`routes/project_files.rs:381-394`, gated on
|
|
30
|
+
// `file_data.name != existing.name`). The sync engine never sends `name`,
|
|
31
|
+
// so a 409 from a content PATCH can only be the CAS.
|
|
32
|
+
// - `DELETE /api/notebooks/files/{id}` has no CAS at all, so its 409 can
|
|
33
|
+
// only be the protection refusal (`:522-523`).
|
|
34
|
+
//
|
|
35
|
+
// `updateFile` and `deleteFile` therefore raise different classes, and
|
|
36
|
+
// neither reads a message body to decide.
|
|
37
|
+
//
|
|
38
|
+
// ## The hash must be byte-identical to the server's
|
|
39
|
+
//
|
|
40
|
+
// `content_hash` is the whole basis of the three-way diff AND of the loop
|
|
41
|
+
// breaker. The server computes `hex_sha256(content)` — sha256 over the UTF-8
|
|
42
|
+
// bytes of the stored String, lowercase hex
|
|
43
|
+
// (`odin_notebook_writeback.rs:1740-1744`). Anything else here — a different
|
|
44
|
+
// encoding, a normalized newline, a trailing-newline fixup — makes `S === L`
|
|
45
|
+
// never fire, and the engine push-pulls the same file forever. That is the
|
|
46
|
+
// runaway-loop class the per-device rate limit now merely bounds.
|
|
47
|
+
import { createHash } from "node:crypto";
|
|
48
|
+
import { request, ApiError } from "../auth/client.js";
|
|
49
|
+
import { eprint } from "../util/log.js";
|
|
50
|
+
// ── the size cap ─────────────────────────────────────────────────────────────
|
|
51
|
+
/**
|
|
52
|
+
* Mirrors `project_file_controller::MAX_FILE_BYTES` (1 MiB), the constant P1
|
|
53
|
+
* unified the three previous caps onto.
|
|
54
|
+
*
|
|
55
|
+
* Kept as a client-side number only to decide what to ATTEMPT and what to report
|
|
56
|
+
* as skipped; the server is the authority and will refuse regardless. A client
|
|
57
|
+
* that silently drops an over-cap file, rather than reporting it, reproduces the
|
|
58
|
+
* container's worst property — work that exists nowhere else and says nothing
|
|
59
|
+
* (`odin_notebook_writeback.rs:1563-1568`).
|
|
60
|
+
*/
|
|
61
|
+
export const MAX_FILE_BYTES = 1_000_000;
|
|
62
|
+
// ⚠ 1,000,000 — DECIMAL, matching the server's
|
|
63
|
+
// `utils/file_import_security.rs:32` exactly. It was `1024 * 1024` (1,048,576),
|
|
64
|
+
// leaving a 48,576-byte band where this client believed a file was carryable and
|
|
65
|
+
// the server refused it. That band is invisible in testing (you have to pick a
|
|
66
|
+
// file between 1,000,000 and 1,048,576 bytes) and is precisely the "whether a
|
|
67
|
+
// file syncs depends on which side you ask" class the server-side unification
|
|
68
|
+
// existed to end — reintroduced on the client. Found by the P6 reviewer.
|
|
69
|
+
// ── errors ───────────────────────────────────────────────────────────────────
|
|
70
|
+
/**
|
|
71
|
+
* The notebook is gone, or is not ours. **Never a delete signal.**
|
|
72
|
+
*
|
|
73
|
+
* Mirrors `writeback_one:1585-1592`, which refuses on an unresolvable owner and
|
|
74
|
+
* keeps every row, while `settle_vanish:513-519` deletes only on an affirmative
|
|
75
|
+
* per-path `Missing`. The two are the same rule: absence of evidence is not
|
|
76
|
+
* evidence of absence.
|
|
77
|
+
*/
|
|
78
|
+
export class NotebookGoneError extends Error {
|
|
79
|
+
notebookId;
|
|
80
|
+
constructor(notebookId) {
|
|
81
|
+
super(`notebook ${notebookId} is not reachable (deleted, or not owned by this account) — ` +
|
|
82
|
+
`the project is frozen offline and no local file was touched`);
|
|
83
|
+
this.notebookId = notebookId;
|
|
84
|
+
this.name = "NotebookGoneError";
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
/** The CAS refused: the server's `content_hash` moved since we read it. */
|
|
88
|
+
export class CasConflictError extends Error {
|
|
89
|
+
fileId;
|
|
90
|
+
detail;
|
|
91
|
+
constructor(fileId, detail) {
|
|
92
|
+
super(`the server copy changed since it was read (${detail})`);
|
|
93
|
+
this.fileId = fileId;
|
|
94
|
+
this.detail = detail;
|
|
95
|
+
this.name = "CasConflictError";
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
/** The file backs a live regime or widget. The local copy must be RESTORED. */
|
|
99
|
+
export class ProtectedFileError extends Error {
|
|
100
|
+
fileId;
|
|
101
|
+
detail;
|
|
102
|
+
constructor(fileId, detail) {
|
|
103
|
+
super(detail);
|
|
104
|
+
this.fileId = fileId;
|
|
105
|
+
this.detail = detail;
|
|
106
|
+
this.name = "ProtectedFileError";
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
/** The per-device limit (600/min) fired. Back off; never spin. */
|
|
110
|
+
export class RateLimitedError extends Error {
|
|
111
|
+
path;
|
|
112
|
+
constructor(path) {
|
|
113
|
+
super(`rate limited by the server on ${path} — backing off`);
|
|
114
|
+
this.path = path;
|
|
115
|
+
this.name = "RateLimitedError";
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
/** Anything else. Kept distinct so a caller cannot mistake it for "missing". */
|
|
119
|
+
export class TransportError extends Error {
|
|
120
|
+
status;
|
|
121
|
+
constructor(status, message) {
|
|
122
|
+
super(message);
|
|
123
|
+
this.status = status;
|
|
124
|
+
this.name = "TransportError";
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
// ── hashing ──────────────────────────────────────────────────────────────────
|
|
128
|
+
/**
|
|
129
|
+
* The server's hash, exactly.
|
|
130
|
+
*
|
|
131
|
+
* `hex_sha256(&content)` = sha256 over `content.as_bytes()`, lowercase hex.
|
|
132
|
+
* Node's `createHash("sha256").update(string)` defaults to utf8, which is the
|
|
133
|
+
* same bytes — but it is spelled out here so nobody "tidies" it into a Buffer
|
|
134
|
+
* with a different default.
|
|
135
|
+
*/
|
|
136
|
+
export function contentHash(text) {
|
|
137
|
+
return createHash("sha256").update(Buffer.from(text, "utf8")).digest("hex");
|
|
138
|
+
}
|
|
139
|
+
/**
|
|
140
|
+
* Strip the canonical leading slash.
|
|
141
|
+
*
|
|
142
|
+
* ⚠ `project_files.path` is stored EITHER canonical (`/a/b.py`) or legacy
|
|
143
|
+
* slashless (`a/b.py`) — `writeback_one:1594-1599` looks up both, in that order,
|
|
144
|
+
* precisely because both exist in production data. The container resolves it the
|
|
145
|
+
* same way we do: `pf.path.trim_start_matches('/')` (`materialize_all:1407`).
|
|
146
|
+
* Normalising on READ and preserving the row's own form on WRITE is what keeps a
|
|
147
|
+
* legacy row from being duplicated as a second, canonical row.
|
|
148
|
+
*/
|
|
149
|
+
function toRelative(serverPath) {
|
|
150
|
+
return serverPath.replace(/^\/+/, "");
|
|
151
|
+
}
|
|
152
|
+
/**
|
|
153
|
+
* The form to CREATE new rows with.
|
|
154
|
+
*
|
|
155
|
+
* Canonical (leading slash), matching what the container's `writeback_one`
|
|
156
|
+
* inserts (`:1672`, binding `&canonical`). A CLI that created slashless rows
|
|
157
|
+
* would produce a second row shape for the same project and defeat the
|
|
158
|
+
* dual-lookup above the day a container touches the same notebook.
|
|
159
|
+
*/
|
|
160
|
+
export function toServerPath(rel) {
|
|
161
|
+
return `/${rel.replace(/^\/+/, "")}`;
|
|
162
|
+
}
|
|
163
|
+
// ── calls ────────────────────────────────────────────────────────────────────
|
|
164
|
+
function classify(err, notebookId, path) {
|
|
165
|
+
if (err instanceof ApiError) {
|
|
166
|
+
if (err.status === 429)
|
|
167
|
+
throw new RateLimitedError(path);
|
|
168
|
+
if (err.status === 404 && notebookId !== null)
|
|
169
|
+
throw new NotebookGoneError(notebookId);
|
|
170
|
+
// 403 on a notebook route is an ownership failure by another name — a Clerk
|
|
171
|
+
// duplicate-row account lands here rather than on 404. Same verdict: freeze.
|
|
172
|
+
if (err.status === 403 && notebookId !== null)
|
|
173
|
+
throw new NotebookGoneError(notebookId);
|
|
174
|
+
throw new TransportError(err.status, err.message);
|
|
175
|
+
}
|
|
176
|
+
throw err;
|
|
177
|
+
}
|
|
178
|
+
export async function listProjects() {
|
|
179
|
+
try {
|
|
180
|
+
const res = await request("/api/cli/projects");
|
|
181
|
+
const body = res.body;
|
|
182
|
+
// Tolerate both an array and a `{projects: []}` envelope — the route is new
|
|
183
|
+
// and this costs one line, where guessing wrong costs an empty project list
|
|
184
|
+
// that reads as "you have no notebooks".
|
|
185
|
+
const list = Array.isArray(body) ? body : (body?.projects ?? []);
|
|
186
|
+
return list.map((p) => ({ id: p.id, name: p.name, dirname: p.dirname }));
|
|
187
|
+
}
|
|
188
|
+
catch (err) {
|
|
189
|
+
return classify(err, null, "/api/cli/projects");
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
/**
|
|
193
|
+
* The whole server side of the diff, in one request.
|
|
194
|
+
*
|
|
195
|
+
* `combined` is the ONLY hash-bearing read on this surface
|
|
196
|
+
* (`shell_token.rs:349-351` says so in the scope list), which is why the engine
|
|
197
|
+
* uses it and not `bulk-content`.
|
|
198
|
+
*
|
|
199
|
+
* ⚠ The response's `files` array carries `{id, content, content_hash}` and **NO
|
|
200
|
+
* PATH** (`schemas/project_file.rs:210-214`). Paths live only in `tree`. The
|
|
201
|
+
* join below is therefore mandatory, not an optimisation — an implementation
|
|
202
|
+
* that assumed a `path` field would silently produce zero usable rows.
|
|
203
|
+
*/
|
|
204
|
+
export async function snapshot(notebookId) {
|
|
205
|
+
const path = `/api/notebooks/${encodeURIComponent(notebookId)}/files/combined`;
|
|
206
|
+
let body;
|
|
207
|
+
try {
|
|
208
|
+
({ body } = await request(path));
|
|
209
|
+
}
|
|
210
|
+
catch (err) {
|
|
211
|
+
return classify(err, notebookId, path);
|
|
212
|
+
}
|
|
213
|
+
const pathById = new Map();
|
|
214
|
+
const typeById = new Map();
|
|
215
|
+
const folders = [];
|
|
216
|
+
const walk = (nodes) => {
|
|
217
|
+
for (const n of nodes ?? []) {
|
|
218
|
+
pathById.set(n.id, toRelative(n.path));
|
|
219
|
+
typeById.set(n.id, n.type);
|
|
220
|
+
if (n.type === "folder")
|
|
221
|
+
folders.push(toRelative(n.path));
|
|
222
|
+
walk(n.children);
|
|
223
|
+
}
|
|
224
|
+
};
|
|
225
|
+
walk(body.tree);
|
|
226
|
+
const files = [];
|
|
227
|
+
for (const f of body.files ?? []) {
|
|
228
|
+
const rel = pathById.get(f.id);
|
|
229
|
+
// A content row with no tree node cannot be placed on disk. Skipping it is
|
|
230
|
+
// right (there is nowhere to write it) but it must not be silent, because
|
|
231
|
+
// the same shape would appear if the tree were ever truncated.
|
|
232
|
+
if (rel === undefined) {
|
|
233
|
+
eprint(`symbols: server file ${f.id} has content but no tree entry — skipped (report this)\n`);
|
|
234
|
+
continue;
|
|
235
|
+
}
|
|
236
|
+
if (typeById.get(f.id) === "folder")
|
|
237
|
+
continue;
|
|
238
|
+
// ⚠ A ROW WITH CONTENT BUT NO HASH IS A DELETE WAITING TO HAPPEN.
|
|
239
|
+
//
|
|
240
|
+
// `content_hash` is `Option<String>` on the wire and NULL for any row
|
|
241
|
+
// written before hashing existed. Passing that `null` through would make the
|
|
242
|
+
// diff read `S = null` = "absent on the server" — and if the file is also
|
|
243
|
+
// absent locally with `B = null`, the table produces `push-delete` and the
|
|
244
|
+
// client deletes a server row holding real content.
|
|
245
|
+
//
|
|
246
|
+
// The server's hash is a pure function of the content
|
|
247
|
+
// (`models/project_file.rs:35-37`), so computing it here recovers the true
|
|
248
|
+
// `S` exactly rather than approximating it. A genuinely content-less row
|
|
249
|
+
// (`content = NULL`) keeps `S = null`, which is correct: `materialize_all`
|
|
250
|
+
// does not write those to disk either (`:1400-1401`).
|
|
251
|
+
const hash = f.content_hash ?? (f.content === null ? null : contentHash(f.content));
|
|
252
|
+
files.push({
|
|
253
|
+
id: f.id,
|
|
254
|
+
path: rel,
|
|
255
|
+
contentHash: hash,
|
|
256
|
+
content: f.content,
|
|
257
|
+
size: f.content === null ? null : Buffer.byteLength(f.content, "utf8"),
|
|
258
|
+
skipped: false,
|
|
259
|
+
});
|
|
260
|
+
}
|
|
261
|
+
// Files omitted for size. P1 added the hash and size here specifically so a
|
|
262
|
+
// sync engine can DIFF them rather than re-download or ignore them forever
|
|
263
|
+
// (`schemas/project_file.rs:231-242`).
|
|
264
|
+
for (const s of body.skipped ?? []) {
|
|
265
|
+
files.push({
|
|
266
|
+
id: s.id,
|
|
267
|
+
path: toRelative(s.path),
|
|
268
|
+
contentHash: s.content_hash,
|
|
269
|
+
content: null,
|
|
270
|
+
size: s.size,
|
|
271
|
+
skipped: true,
|
|
272
|
+
});
|
|
273
|
+
}
|
|
274
|
+
return { files, folders };
|
|
275
|
+
}
|
|
276
|
+
export async function artifacts(notebookId) {
|
|
277
|
+
const path = `/api/notebooks/${encodeURIComponent(notebookId)}/files/artifacts`;
|
|
278
|
+
try {
|
|
279
|
+
const { body } = await request(path);
|
|
280
|
+
return body ?? {};
|
|
281
|
+
}
|
|
282
|
+
catch (err) {
|
|
283
|
+
return classify(err, notebookId, path);
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
export async function createFile(notebookId, rel, content) {
|
|
287
|
+
const path = `/api/notebooks/${encodeURIComponent(notebookId)}/files`;
|
|
288
|
+
const name = rel.split("/").pop() ?? rel;
|
|
289
|
+
try {
|
|
290
|
+
const { body } = await request(path, {
|
|
291
|
+
method: "POST",
|
|
292
|
+
body: { name, path: toServerPath(rel), file_type: "file", content },
|
|
293
|
+
});
|
|
294
|
+
return { id: body.id };
|
|
295
|
+
}
|
|
296
|
+
catch (err) {
|
|
297
|
+
return classify(err, notebookId, path);
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
/**
|
|
301
|
+
* Push content with compare-and-swap.
|
|
302
|
+
*
|
|
303
|
+
* ⚠ `base_content_hash` IS NOT OPTIONAL FOR US, even though the server makes it
|
|
304
|
+
* optional (`schemas/project_file.rs:79-80`, kept optional so the web editor and
|
|
305
|
+
* the container writeback are unaffected). Omitting it restores the blind
|
|
306
|
+
* overwrite this whole design exists to eliminate — the client decides "the
|
|
307
|
+
* server has not moved", the tile saves in the gap, and that edit is gone with
|
|
308
|
+
* no trace. Every push from this client opts in.
|
|
309
|
+
*
|
|
310
|
+
* `null` means "we believe there is no server content yet"; the server compares
|
|
311
|
+
* against `""` in that case (`routes/project_files.rs:426`, `unwrap_or("")`), so
|
|
312
|
+
* the empty string is the correct wire value for it.
|
|
313
|
+
*
|
|
314
|
+
* ⚠ `name` is deliberately NOT sent. The regime-protection check on this route
|
|
315
|
+
* fires only when `name` changes, so leaving it out keeps a 409 unambiguous.
|
|
316
|
+
*/
|
|
317
|
+
export async function updateFile(fileId, content, baseContentHash) {
|
|
318
|
+
const path = `/api/notebooks/files/${encodeURIComponent(fileId)}`;
|
|
319
|
+
try {
|
|
320
|
+
await request(path, {
|
|
321
|
+
method: "PATCH",
|
|
322
|
+
body: { content, base_content_hash: baseContentHash ?? "" },
|
|
323
|
+
});
|
|
324
|
+
}
|
|
325
|
+
catch (err) {
|
|
326
|
+
if (err instanceof ApiError && err.status === 409) {
|
|
327
|
+
throw new CasConflictError(fileId, err.message);
|
|
328
|
+
}
|
|
329
|
+
if (err instanceof ApiError && err.status === 404) {
|
|
330
|
+
// A FILE 404, not a notebook 404 — this route is keyed on file id. The row
|
|
331
|
+
// is gone; the caller re-diffs and will create it if the local copy stands.
|
|
332
|
+
throw new TransportError(404, `file ${fileId} no longer exists on the server`);
|
|
333
|
+
}
|
|
334
|
+
return classify(err, null, path);
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
/**
|
|
338
|
+
* Delete a row.
|
|
339
|
+
*
|
|
340
|
+
* A 409 here can ONLY be the regime/widget protection — this route has no CAS
|
|
341
|
+
* (`routes/project_files.rs:505-538`). A 404 means it is already gone, which is
|
|
342
|
+
* the outcome we wanted, so it succeeds.
|
|
343
|
+
*/
|
|
344
|
+
export async function deleteFile(fileId) {
|
|
345
|
+
const path = `/api/notebooks/files/${encodeURIComponent(fileId)}`;
|
|
346
|
+
try {
|
|
347
|
+
await request(path, { method: "DELETE" });
|
|
348
|
+
}
|
|
349
|
+
catch (err) {
|
|
350
|
+
if (err instanceof ApiError && err.status === 409) {
|
|
351
|
+
throw new ProtectedFileError(fileId, err.message);
|
|
352
|
+
}
|
|
353
|
+
if (err instanceof ApiError && err.status === 404)
|
|
354
|
+
return; // already gone
|
|
355
|
+
return classify(err, null, path);
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
export async function createNotebook(name) {
|
|
359
|
+
try {
|
|
360
|
+
const { body } = await request("/api/notebooks", {
|
|
361
|
+
method: "POST",
|
|
362
|
+
body: { name },
|
|
363
|
+
});
|
|
364
|
+
return body;
|
|
365
|
+
}
|
|
366
|
+
catch (err) {
|
|
367
|
+
return classify(err, null, "/api/notebooks");
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
export async function deleteNotebook(notebookId) {
|
|
371
|
+
const path = `/api/notebooks/${encodeURIComponent(notebookId)}`;
|
|
372
|
+
try {
|
|
373
|
+
await request(path, { method: "DELETE" });
|
|
374
|
+
}
|
|
375
|
+
catch (err) {
|
|
376
|
+
if (err instanceof ApiError && err.status === 404)
|
|
377
|
+
return;
|
|
378
|
+
return classify(err, null, path);
|
|
379
|
+
}
|
|
380
|
+
}
|