@khanglvm/outline-cli 0.1.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/.env.test.example +2 -0
- package/AGENTS.md +107 -0
- package/CHANGELOG.md +102 -0
- package/README.md +244 -0
- package/bin/outline-agent.js +5 -0
- package/bin/outline-cli.js +13 -0
- package/package.json +25 -0
- package/scripts/generate-entry-integrity.mjs +123 -0
- package/scripts/release.mjs +353 -0
- package/src/action-gate.js +257 -0
- package/src/agent-skills.js +759 -0
- package/src/cli.js +956 -0
- package/src/config-store.js +720 -0
- package/src/entry-integrity-binding.generated.js +6 -0
- package/src/entry-integrity-manifest.generated.js +74 -0
- package/src/entry-integrity.js +112 -0
- package/src/errors.js +15 -0
- package/src/outline-client.js +237 -0
- package/src/result-store.js +183 -0
- package/src/secure-keyring.js +290 -0
- package/src/tool-arg-schemas.js +2346 -0
- package/src/tools.extended.js +3252 -0
- package/src/tools.js +1056 -0
- package/src/tools.mutation.js +1807 -0
- package/src/tools.navigation.js +2273 -0
- package/src/tools.platform.js +554 -0
- package/src/utils.js +176 -0
- package/test/action-gate.unit.test.js +157 -0
- package/test/agent-skills.unit.test.js +52 -0
- package/test/config-store.unit.test.js +89 -0
- package/test/hardening.unit.test.js +3778 -0
- package/test/live.integration.test.js +5140 -0
- package/test/profile-selection.unit.test.js +279 -0
- package/test/security.unit.test.js +113 -0
|
@@ -0,0 +1,353 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import fs from "node:fs";
|
|
4
|
+
import fsp from "node:fs/promises";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
import { spawnSync } from "node:child_process";
|
|
7
|
+
import { fileURLToPath } from "node:url";
|
|
8
|
+
|
|
9
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
10
|
+
const repoRoot = path.resolve(__dirname, "..");
|
|
11
|
+
|
|
12
|
+
function usage() {
|
|
13
|
+
process.stdout.write(
|
|
14
|
+
[
|
|
15
|
+
"Usage:",
|
|
16
|
+
" node ./scripts/release.mjs [--bump <patch|minor|major|prepatch|preminor|premajor|prerelease> | --version <x.y.z>]",
|
|
17
|
+
" [--tag <dist-tag>] [--otp <code>] [--access <public|restricted>]",
|
|
18
|
+
" [--no-publish] [--no-push] [--skip-check] [--skip-test] [--allow-dirty]",
|
|
19
|
+
"",
|
|
20
|
+
"Examples:",
|
|
21
|
+
" npm run release -- --bump patch",
|
|
22
|
+
" npm run release -- --version 0.2.0",
|
|
23
|
+
" npm run release -- --bump minor --tag next --no-publish --no-push",
|
|
24
|
+
"",
|
|
25
|
+
].join("\n")
|
|
26
|
+
);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function parseArgs(argv) {
|
|
30
|
+
let sawBump = false;
|
|
31
|
+
let sawVersion = false;
|
|
32
|
+
function requireValue(flag, index) {
|
|
33
|
+
const value = argv[index + 1];
|
|
34
|
+
if (!value || value.startsWith("--")) {
|
|
35
|
+
throw new Error(`Missing value for ${flag}`);
|
|
36
|
+
}
|
|
37
|
+
return value;
|
|
38
|
+
}
|
|
39
|
+
const opts = {
|
|
40
|
+
bump: "patch",
|
|
41
|
+
version: null,
|
|
42
|
+
tag: "latest",
|
|
43
|
+
otp: null,
|
|
44
|
+
access: "public",
|
|
45
|
+
publish: true,
|
|
46
|
+
push: true,
|
|
47
|
+
skipCheck: false,
|
|
48
|
+
skipTest: false,
|
|
49
|
+
allowDirty: false,
|
|
50
|
+
changelogPath: path.join(repoRoot, "CHANGELOG.md"),
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
for (let i = 0; i < argv.length; i += 1) {
|
|
54
|
+
const arg = argv[i];
|
|
55
|
+
if (arg === "--help" || arg === "-h") {
|
|
56
|
+
opts.help = true;
|
|
57
|
+
continue;
|
|
58
|
+
}
|
|
59
|
+
if (arg === "--bump") {
|
|
60
|
+
opts.bump = requireValue("--bump", i);
|
|
61
|
+
sawBump = true;
|
|
62
|
+
i += 1;
|
|
63
|
+
continue;
|
|
64
|
+
}
|
|
65
|
+
if (arg === "--version") {
|
|
66
|
+
opts.version = requireValue("--version", i);
|
|
67
|
+
sawVersion = true;
|
|
68
|
+
i += 1;
|
|
69
|
+
continue;
|
|
70
|
+
}
|
|
71
|
+
if (arg === "--tag") {
|
|
72
|
+
opts.tag = requireValue("--tag", i);
|
|
73
|
+
i += 1;
|
|
74
|
+
continue;
|
|
75
|
+
}
|
|
76
|
+
if (arg === "--otp") {
|
|
77
|
+
opts.otp = requireValue("--otp", i);
|
|
78
|
+
i += 1;
|
|
79
|
+
continue;
|
|
80
|
+
}
|
|
81
|
+
if (arg === "--access") {
|
|
82
|
+
opts.access = requireValue("--access", i);
|
|
83
|
+
i += 1;
|
|
84
|
+
continue;
|
|
85
|
+
}
|
|
86
|
+
if (arg === "--changelog") {
|
|
87
|
+
opts.changelogPath = path.resolve(repoRoot, requireValue("--changelog", i));
|
|
88
|
+
i += 1;
|
|
89
|
+
continue;
|
|
90
|
+
}
|
|
91
|
+
if (arg === "--no-publish") {
|
|
92
|
+
opts.publish = false;
|
|
93
|
+
continue;
|
|
94
|
+
}
|
|
95
|
+
if (arg === "--no-push") {
|
|
96
|
+
opts.push = false;
|
|
97
|
+
continue;
|
|
98
|
+
}
|
|
99
|
+
if (arg === "--skip-check") {
|
|
100
|
+
opts.skipCheck = true;
|
|
101
|
+
continue;
|
|
102
|
+
}
|
|
103
|
+
if (arg === "--skip-test") {
|
|
104
|
+
opts.skipTest = true;
|
|
105
|
+
continue;
|
|
106
|
+
}
|
|
107
|
+
if (arg === "--allow-dirty") {
|
|
108
|
+
opts.allowDirty = true;
|
|
109
|
+
continue;
|
|
110
|
+
}
|
|
111
|
+
throw new Error(`Unknown argument: ${arg}`);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
if (sawVersion && sawBump) {
|
|
115
|
+
throw new Error("Use either --version or --bump, not both.");
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
if (!opts.version && !opts.bump) {
|
|
119
|
+
throw new Error("Missing version strategy. Provide --version <x.y.z> or --bump <type>.");
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const validBumps = new Set(["patch", "minor", "major", "prepatch", "preminor", "premajor", "prerelease"]);
|
|
123
|
+
if (!opts.version && !validBumps.has(opts.bump)) {
|
|
124
|
+
throw new Error(`Invalid --bump value: ${opts.bump}`);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
return opts;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function loadDotEnvFileIfPresent(filePath) {
|
|
131
|
+
if (!fs.existsSync(filePath)) {
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
const raw = fs.readFileSync(filePath, "utf8");
|
|
135
|
+
for (const line of raw.split(/\r?\n/)) {
|
|
136
|
+
const trimmed = line.trim();
|
|
137
|
+
if (!trimmed || trimmed.startsWith("#")) {
|
|
138
|
+
continue;
|
|
139
|
+
}
|
|
140
|
+
const i = trimmed.indexOf("=");
|
|
141
|
+
if (i <= 0) {
|
|
142
|
+
continue;
|
|
143
|
+
}
|
|
144
|
+
const key = trimmed.slice(0, i).trim();
|
|
145
|
+
let value = trimmed.slice(i + 1).trim();
|
|
146
|
+
if (
|
|
147
|
+
(value.startsWith('"') && value.endsWith('"')) ||
|
|
148
|
+
(value.startsWith("'") && value.endsWith("'"))
|
|
149
|
+
) {
|
|
150
|
+
value = value.slice(1, -1);
|
|
151
|
+
}
|
|
152
|
+
if (process.env[key] == null) {
|
|
153
|
+
process.env[key] = value;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function run(cmd, args, options = {}) {
|
|
159
|
+
const capture = options.capture === true;
|
|
160
|
+
const display = `$ ${cmd} ${args.join(" ")}`;
|
|
161
|
+
process.stdout.write(`${display}\n`);
|
|
162
|
+
const res = spawnSync(cmd, args, {
|
|
163
|
+
cwd: repoRoot,
|
|
164
|
+
encoding: "utf8",
|
|
165
|
+
stdio: capture ? ["ignore", "pipe", "pipe"] : "inherit",
|
|
166
|
+
env: { ...process.env, ...(options.env || {}) },
|
|
167
|
+
});
|
|
168
|
+
if (res.status !== 0) {
|
|
169
|
+
const extra = capture ? `\n${(res.stdout || "").trim()}\n${(res.stderr || "").trim()}` : "";
|
|
170
|
+
throw new Error(`Command failed (${res.status}): ${display}${extra}`);
|
|
171
|
+
}
|
|
172
|
+
return capture ? (res.stdout || "").trim() : "";
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function readJson(filePath) {
|
|
176
|
+
return JSON.parse(fs.readFileSync(filePath, "utf8"));
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function currentDateIso() {
|
|
180
|
+
const now = new Date();
|
|
181
|
+
const yyyy = String(now.getUTCFullYear());
|
|
182
|
+
const mm = String(now.getUTCMonth() + 1).padStart(2, "0");
|
|
183
|
+
const dd = String(now.getUTCDate()).padStart(2, "0");
|
|
184
|
+
return `${yyyy}-${mm}-${dd}`;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function getLatestSemverTag() {
|
|
188
|
+
const tags = run("git", ["tag", "--list", "v*.*.*", "--sort=-version:refname"], { capture: true })
|
|
189
|
+
.split(/\r?\n/)
|
|
190
|
+
.map((line) => line.trim())
|
|
191
|
+
.filter(Boolean);
|
|
192
|
+
return tags[0] || null;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function listCommitSubjectsSince(tag) {
|
|
196
|
+
const range = tag ? `${tag}..HEAD` : "HEAD";
|
|
197
|
+
const out = run("git", ["log", range, "--pretty=format:%s (%h)"], { capture: true });
|
|
198
|
+
const rows = out
|
|
199
|
+
.split(/\r?\n/)
|
|
200
|
+
.map((line) => line.trim())
|
|
201
|
+
.filter(Boolean);
|
|
202
|
+
return rows.length > 0 ? rows : ["Maintenance release."];
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
async function updateChangelog({ changelogPath, nextVersion, previousTag }) {
|
|
206
|
+
let content = "";
|
|
207
|
+
if (fs.existsSync(changelogPath)) {
|
|
208
|
+
content = await fsp.readFile(changelogPath, "utf8");
|
|
209
|
+
} else {
|
|
210
|
+
content = "# Changelog\n\n";
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
if (!content.startsWith("# Changelog")) {
|
|
214
|
+
content = `# Changelog\n\n${content.trimStart()}`;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
const existingHeading = new RegExp(`^##\\s+${nextVersion}\\s+-\\s+`, "m");
|
|
218
|
+
if (existingHeading.test(content)) {
|
|
219
|
+
throw new Error(`CHANGELOG already has an entry for ${nextVersion}`);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
const commits = listCommitSubjectsSince(previousTag);
|
|
223
|
+
const scopeLine = previousTag ? `- Changes since \`${previousTag}\`.` : "- Initial tagged release notes.";
|
|
224
|
+
const commitLines = commits.map((line) => `- ${line}`).join("\n");
|
|
225
|
+
const entry = `## ${nextVersion} - ${currentDateIso()}\n\n${scopeLine}\n${commitLines}\n`;
|
|
226
|
+
|
|
227
|
+
const header = "# Changelog";
|
|
228
|
+
const body = content.slice(header.length).trimStart();
|
|
229
|
+
const next = `${header}\n\n${entry}\n${body}`.replace(/\n{3,}/g, "\n\n");
|
|
230
|
+
await fsp.writeFile(changelogPath, `${next.trimEnd()}\n`, "utf8");
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function ensureBuildKey() {
|
|
234
|
+
loadDotEnvFileIfPresent(path.join(repoRoot, ".env.local"));
|
|
235
|
+
const key = process.env.OUTLINE_ENTRY_BUILD_KEY;
|
|
236
|
+
if (!key || String(key).trim().length < 24) {
|
|
237
|
+
throw new Error(
|
|
238
|
+
"OUTLINE_ENTRY_BUILD_KEY is required for release integrity binding. Set it in environment or .env.local."
|
|
239
|
+
);
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
function ensureGitClean(allowDirty) {
|
|
244
|
+
if (allowDirty) {
|
|
245
|
+
return;
|
|
246
|
+
}
|
|
247
|
+
const dirty = run("git", ["status", "--porcelain"], { capture: true });
|
|
248
|
+
if (dirty.trim()) {
|
|
249
|
+
throw new Error("Git working tree is not clean. Commit/stash changes or pass --allow-dirty.");
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
function ensureTagDoesNotExist(tagName) {
|
|
254
|
+
const out = run("git", ["tag", "--list", tagName], { capture: true });
|
|
255
|
+
if (out.trim() === tagName) {
|
|
256
|
+
throw new Error(`Git tag already exists: ${tagName}`);
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
function bumpVersion(opts) {
|
|
261
|
+
const arg = opts.version || opts.bump;
|
|
262
|
+
const out = run("npm", ["version", arg, "--no-git-tag-version"], { capture: true });
|
|
263
|
+
const newVersion = out.trim().replace(/^v/, "");
|
|
264
|
+
if (!newVersion) {
|
|
265
|
+
throw new Error("Unable to resolve next version from npm version output.");
|
|
266
|
+
}
|
|
267
|
+
return newVersion;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
function publishPackage(opts) {
|
|
271
|
+
const args = ["publish", "--access", opts.access];
|
|
272
|
+
if (opts.tag && opts.tag !== "latest") {
|
|
273
|
+
args.push("--tag", opts.tag);
|
|
274
|
+
}
|
|
275
|
+
if (opts.otp) {
|
|
276
|
+
args.push("--otp", opts.otp);
|
|
277
|
+
}
|
|
278
|
+
run("npm", args);
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
async function main() {
|
|
282
|
+
const opts = parseArgs(process.argv.slice(2));
|
|
283
|
+
if (opts.help) {
|
|
284
|
+
usage();
|
|
285
|
+
return;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
ensureBuildKey();
|
|
289
|
+
ensureGitClean(opts.allowDirty);
|
|
290
|
+
|
|
291
|
+
const packageJsonPath = path.join(repoRoot, "package.json");
|
|
292
|
+
const beforePkg = readJson(packageJsonPath);
|
|
293
|
+
const previousTag = getLatestSemverTag();
|
|
294
|
+
|
|
295
|
+
const branch = run("git", ["rev-parse", "--abbrev-ref", "HEAD"], { capture: true });
|
|
296
|
+
if (!branch || branch === "HEAD") {
|
|
297
|
+
throw new Error("Detached HEAD is not supported for release. Check out a branch.");
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
const nextVersion = bumpVersion(opts);
|
|
301
|
+
const releaseTag = `v${nextVersion}`;
|
|
302
|
+
ensureTagDoesNotExist(releaseTag);
|
|
303
|
+
await updateChangelog({
|
|
304
|
+
changelogPath: opts.changelogPath,
|
|
305
|
+
nextVersion,
|
|
306
|
+
previousTag,
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
run("npm", ["run", "integrity:refresh"]);
|
|
310
|
+
if (!opts.skipCheck) {
|
|
311
|
+
run("npm", ["run", "check"]);
|
|
312
|
+
}
|
|
313
|
+
if (!opts.skipTest) {
|
|
314
|
+
run("npm", ["test"]);
|
|
315
|
+
}
|
|
316
|
+
run("npm", ["pack", "--dry-run"]);
|
|
317
|
+
|
|
318
|
+
run("git", ["add", "-A"]);
|
|
319
|
+
run("git", ["commit", "-m", `chore(release): v${nextVersion}`]);
|
|
320
|
+
run("git", ["tag", "-a", releaseTag, "-m", releaseTag]);
|
|
321
|
+
|
|
322
|
+
if (opts.publish) {
|
|
323
|
+
publishPackage(opts);
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
if (opts.push) {
|
|
327
|
+
run("git", ["push", "origin", branch]);
|
|
328
|
+
run("git", ["push", "origin", releaseTag]);
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
const afterPkg = readJson(packageJsonPath);
|
|
332
|
+
process.stdout.write(
|
|
333
|
+
`${JSON.stringify(
|
|
334
|
+
{
|
|
335
|
+
ok: true,
|
|
336
|
+
package: beforePkg.name,
|
|
337
|
+
previousVersion: beforePkg.version,
|
|
338
|
+
nextVersion: afterPkg.version,
|
|
339
|
+
releaseTag,
|
|
340
|
+
published: opts.publish,
|
|
341
|
+
pushed: opts.push,
|
|
342
|
+
branch,
|
|
343
|
+
},
|
|
344
|
+
null,
|
|
345
|
+
2
|
|
346
|
+
)}\n`
|
|
347
|
+
);
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
main().catch((err) => {
|
|
351
|
+
process.stderr.write(`${err?.stack || err}\n`);
|
|
352
|
+
process.exit(1);
|
|
353
|
+
});
|
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import fs from "node:fs/promises";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { defaultTmpDir } from "./config-store.js";
|
|
5
|
+
import { CliError } from "./errors.js";
|
|
6
|
+
|
|
7
|
+
const STORE_VERSION = 1;
|
|
8
|
+
|
|
9
|
+
function gateStorePath() {
|
|
10
|
+
return path.join(defaultTmpDir(), "action-gates", "delete-read-receipts.json");
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function blankStore() {
|
|
14
|
+
return {
|
|
15
|
+
version: STORE_VERSION,
|
|
16
|
+
receipts: {},
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function normalizeTtlSeconds(ttlSeconds) {
|
|
21
|
+
const parsed = Number(ttlSeconds);
|
|
22
|
+
if (!Number.isFinite(parsed)) {
|
|
23
|
+
return 900;
|
|
24
|
+
}
|
|
25
|
+
return Math.max(60, Math.min(86_400, Math.trunc(parsed)));
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function pruneExpired(store) {
|
|
29
|
+
const receipts = store.receipts && typeof store.receipts === "object" ? store.receipts : {};
|
|
30
|
+
const now = Date.now();
|
|
31
|
+
let changed = false;
|
|
32
|
+
|
|
33
|
+
for (const [token, receipt] of Object.entries(receipts)) {
|
|
34
|
+
const expiresAt = Date.parse(receipt?.expiresAt || "");
|
|
35
|
+
if (!Number.isFinite(expiresAt) || expiresAt <= now) {
|
|
36
|
+
delete receipts[token];
|
|
37
|
+
changed = true;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
store.receipts = receipts;
|
|
42
|
+
return changed;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
async function loadStore() {
|
|
46
|
+
const file = gateStorePath();
|
|
47
|
+
try {
|
|
48
|
+
const raw = await fs.readFile(file, "utf8");
|
|
49
|
+
const parsed = JSON.parse(raw);
|
|
50
|
+
if (!parsed || typeof parsed !== "object") {
|
|
51
|
+
return blankStore();
|
|
52
|
+
}
|
|
53
|
+
if (parsed.version !== STORE_VERSION) {
|
|
54
|
+
return blankStore();
|
|
55
|
+
}
|
|
56
|
+
if (!parsed.receipts || typeof parsed.receipts !== "object") {
|
|
57
|
+
parsed.receipts = {};
|
|
58
|
+
}
|
|
59
|
+
return parsed;
|
|
60
|
+
} catch (err) {
|
|
61
|
+
if (err && err.code === "ENOENT") {
|
|
62
|
+
return blankStore();
|
|
63
|
+
}
|
|
64
|
+
throw err;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
async function saveStore(store) {
|
|
69
|
+
const file = gateStorePath();
|
|
70
|
+
await fs.mkdir(path.dirname(file), { recursive: true });
|
|
71
|
+
await fs.writeFile(file, `${JSON.stringify(store, null, 2)}\n`, { mode: 0o600 });
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function assertPerformAction(args, { tool, action }) {
|
|
75
|
+
if (args?.performAction === true) {
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
throw new CliError(`${tool} is action-gated. Set args.performAction=true to continue.`, {
|
|
80
|
+
code: "ACTION_GATED",
|
|
81
|
+
tool,
|
|
82
|
+
action,
|
|
83
|
+
required: {
|
|
84
|
+
performAction: true,
|
|
85
|
+
},
|
|
86
|
+
provided: {
|
|
87
|
+
performAction: args?.performAction ?? false,
|
|
88
|
+
},
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function normalizeMethodName(method) {
|
|
93
|
+
return String(method || "")
|
|
94
|
+
.split(".")
|
|
95
|
+
.map((segment) =>
|
|
96
|
+
String(segment || "")
|
|
97
|
+
.replace(/([a-z0-9])([A-Z])/g, "$1_$2")
|
|
98
|
+
.replace(/-/g, "_")
|
|
99
|
+
.toLowerCase()
|
|
100
|
+
)
|
|
101
|
+
.join(".");
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export function isLikelyMutatingMethod(method) {
|
|
105
|
+
const normalized = normalizeMethodName(method);
|
|
106
|
+
if (!normalized) {
|
|
107
|
+
return false;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const operation = normalized.split(".").at(-1) || "";
|
|
111
|
+
if (!operation) {
|
|
112
|
+
return false;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
if (/^(list|info|search|search_titles|memberships|group_memberships|archived|deleted)$/.test(operation)) {
|
|
116
|
+
return false;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
if (/^(import|export)(_|$)/.test(operation)) {
|
|
120
|
+
return true;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
return (
|
|
124
|
+
/(^|_)(create|update|delete|permanent_delete|permanentdelete|restore|archive|unarchive|move|rename|patch|apply|publish|unpublish|batch|duplicate|templatize|invite|revoke|import|export|empty_trash|emptytrash|suspend|activate|deactivate)(_|$)/.test(
|
|
125
|
+
operation
|
|
126
|
+
) ||
|
|
127
|
+
/^(add|remove|invite|revoke)_/.test(operation)
|
|
128
|
+
);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export function isLikelyDeleteMethod(method) {
|
|
132
|
+
const normalized = normalizeMethodName(method);
|
|
133
|
+
if (!normalized) {
|
|
134
|
+
return false;
|
|
135
|
+
}
|
|
136
|
+
const canonical = normalized.replace(/\.permanentdelete$/, ".permanent_delete");
|
|
137
|
+
return canonical === "documents.delete" || canonical === "documents.permanent_delete";
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export async function issueDocumentDeleteReadReceipt({
|
|
141
|
+
profileId,
|
|
142
|
+
documentId,
|
|
143
|
+
revision,
|
|
144
|
+
title,
|
|
145
|
+
ttlSeconds,
|
|
146
|
+
}) {
|
|
147
|
+
const id = String(documentId || "");
|
|
148
|
+
if (!id) {
|
|
149
|
+
throw new CliError("Cannot issue delete-read receipt without document id", {
|
|
150
|
+
code: "DELETE_READ_RECEIPT_INVALID",
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
if (!profileId) {
|
|
154
|
+
throw new CliError("Cannot issue delete-read receipt without profile id", {
|
|
155
|
+
code: "DELETE_READ_RECEIPT_INVALID",
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const ttl = normalizeTtlSeconds(ttlSeconds);
|
|
160
|
+
const now = Date.now();
|
|
161
|
+
const token = randomUUID();
|
|
162
|
+
const receipt = {
|
|
163
|
+
kind: "document.delete.read",
|
|
164
|
+
profileId: String(profileId),
|
|
165
|
+
documentId: id,
|
|
166
|
+
revision: Number.isFinite(Number(revision)) ? Number(revision) : null,
|
|
167
|
+
title: title ? String(title) : null,
|
|
168
|
+
issuedAt: new Date(now).toISOString(),
|
|
169
|
+
expiresAt: new Date(now + ttl * 1000).toISOString(),
|
|
170
|
+
};
|
|
171
|
+
|
|
172
|
+
const store = await loadStore();
|
|
173
|
+
pruneExpired(store);
|
|
174
|
+
store.receipts[token] = receipt;
|
|
175
|
+
await saveStore(store);
|
|
176
|
+
|
|
177
|
+
return {
|
|
178
|
+
token,
|
|
179
|
+
documentId: receipt.documentId,
|
|
180
|
+
revision: receipt.revision,
|
|
181
|
+
title: receipt.title,
|
|
182
|
+
issuedAt: receipt.issuedAt,
|
|
183
|
+
expiresAt: receipt.expiresAt,
|
|
184
|
+
ttlSeconds: ttl,
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
export async function getDocumentDeleteReadReceipt({
|
|
189
|
+
token,
|
|
190
|
+
profileId,
|
|
191
|
+
documentId,
|
|
192
|
+
}) {
|
|
193
|
+
if (!token || typeof token !== "string") {
|
|
194
|
+
throw new CliError("Delete is gated by read confirmation. Provide args.readToken from documents.info armDelete=true", {
|
|
195
|
+
code: "DELETE_READ_TOKEN_REQUIRED",
|
|
196
|
+
required: ["readToken"],
|
|
197
|
+
});
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
const store = await loadStore();
|
|
201
|
+
const pruned = pruneExpired(store);
|
|
202
|
+
const receipt = store.receipts[token];
|
|
203
|
+
if (pruned) {
|
|
204
|
+
await saveStore(store);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
if (!receipt) {
|
|
208
|
+
throw new CliError("Delete read token is invalid or expired", {
|
|
209
|
+
code: "DELETE_READ_TOKEN_INVALID",
|
|
210
|
+
token,
|
|
211
|
+
});
|
|
212
|
+
}
|
|
213
|
+
if (receipt.kind !== "document.delete.read") {
|
|
214
|
+
throw new CliError("Delete read token has unsupported type", {
|
|
215
|
+
code: "DELETE_READ_TOKEN_INVALID",
|
|
216
|
+
token,
|
|
217
|
+
});
|
|
218
|
+
}
|
|
219
|
+
if (String(receipt.profileId) !== String(profileId)) {
|
|
220
|
+
throw new CliError("Delete read token was created by a different profile", {
|
|
221
|
+
code: "DELETE_READ_TOKEN_PROFILE_MISMATCH",
|
|
222
|
+
token,
|
|
223
|
+
profileId,
|
|
224
|
+
});
|
|
225
|
+
}
|
|
226
|
+
if (String(receipt.documentId) !== String(documentId)) {
|
|
227
|
+
throw new CliError("Delete read token was not issued for this document", {
|
|
228
|
+
code: "DELETE_READ_TOKEN_DOCUMENT_MISMATCH",
|
|
229
|
+
token,
|
|
230
|
+
documentId,
|
|
231
|
+
expectedDocumentId: receipt.documentId,
|
|
232
|
+
});
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
return {
|
|
236
|
+
token,
|
|
237
|
+
...receipt,
|
|
238
|
+
};
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
export async function consumeDocumentDeleteReadReceipt(token) {
|
|
242
|
+
if (!token || typeof token !== "string") {
|
|
243
|
+
return false;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
const store = await loadStore();
|
|
247
|
+
if (!store.receipts[token]) {
|
|
248
|
+
return false;
|
|
249
|
+
}
|
|
250
|
+
delete store.receipts[token];
|
|
251
|
+
await saveStore(store);
|
|
252
|
+
return true;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
export function getActionGateStorePath() {
|
|
256
|
+
return gateStorePath();
|
|
257
|
+
}
|