atris 3.79.0 → 3.80.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/README.md +6 -0
- package/atris/atris.md +66 -167
- package/atris/skills/security-audit/SKILL.md +77 -0
- package/atris/team/brainstormer/MEMBER.md +2 -2
- package/atris.md +66 -167
- package/bin/atris.js +6 -0
- package/commands/auth.js +2 -2
- package/commands/business.js +44 -1984
- package/commands/computer.js +165 -2412
- package/commands/member.js +189 -966
- package/commands/mission.js +229 -1409
- package/commands/pack.js +112 -1476
- package/commands/security-review.js +129 -0
- package/commands/site-deploy.js +5 -1
- package/commands/task.js +137 -1359
- package/lib/business-core.js +2022 -0
- package/lib/computer-core.js +2447 -0
- package/lib/engine-ask.js +1 -0
- package/lib/handoff-wake.js +8 -6
- package/lib/known-commands.js +1 -1
- package/lib/member-autoland.js +21 -2
- package/lib/member-board.js +12 -10
- package/lib/member-core.js +1014 -0
- package/lib/member-up.js +46 -3
- package/lib/mission-core.js +1559 -0
- package/lib/pack-core.js +1588 -0
- package/lib/security-audit-classes.js +318 -0
- package/lib/security-audit.js +1549 -0
- package/lib/task-core.js +1412 -0
- package/lib/task-decision.js +66 -0
- package/lib/task-explanation.js +20 -0
- package/lib/yes-lock.js +1 -1
- package/package.json +9 -2
- package/utils/auth.js +18 -4
package/lib/pack-core.js
ADDED
|
@@ -0,0 +1,1588 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
const { createHash } = require('crypto');
|
|
6
|
+
const { apiRequestJson, getApiBaseUrl, getAppBaseUrl, httpRequest } = require('../utils/api');
|
|
7
|
+
const { loadCredentials, performTokenRefresh } = require('../utils/auth');
|
|
8
|
+
const { ZIP_LIMITS } = require('./zip');
|
|
9
|
+
const { assertPackCapabilityPolicy } = require('./pack-capabilities');
|
|
10
|
+
|
|
11
|
+
const REGISTRY_TIMEOUT_MS = 60000;
|
|
12
|
+
const PACK_RUN_INPUT_MAX_BYTES = 256 * 1024;
|
|
13
|
+
const PACK_VISIBILITIES = new Set(['public', 'unlisted', 'private']);
|
|
14
|
+
const DEFAULT_SHARE_DAYS = 30;
|
|
15
|
+
const MAX_SHARE_DAYS = 365;
|
|
16
|
+
const MAX_SHARE_RECIPIENT_LENGTH = 120;
|
|
17
|
+
const SHARE_NONCE_RULE = /^[A-Za-z0-9_-]{22}$/;
|
|
18
|
+
const PACK_BROWSE_LIMIT = 50;
|
|
19
|
+
|
|
20
|
+
// The web registry caps every upload (atrisos-web app/api/pack/registry/route.ts).
|
|
21
|
+
// The ZIP reader uses the same limits so inbound packs cannot expand past them.
|
|
22
|
+
const REGISTRY_LIMITS = ZIP_LIMITS;
|
|
23
|
+
|
|
24
|
+
// The web viewer rejects anything else (atrisos-web app/lib/pack/manifests.ts).
|
|
25
|
+
const SLUG_RULE = /^[a-z0-9-]{3,40}$/;
|
|
26
|
+
|
|
27
|
+
// ── The packet allowlist ────────────────────────────────────────────────────
|
|
28
|
+
// A packet is the shareable knowledge spine of a workspace, not the folder it
|
|
29
|
+
// lives in. Only what a stranger could read and learn from ships: the map and
|
|
30
|
+
// readme layer, member and team definitions, the wiki, skills, policies, refs,
|
|
31
|
+
// features. Runtime exhaust (runs/, logs/, journal/, status/, .atris/),
|
|
32
|
+
// dependencies, binaries and lockfiles never ship.
|
|
33
|
+
//
|
|
34
|
+
// This table is the whole rule. Audit it here, not in scattered ifs.
|
|
35
|
+
// (2026-07-27: the denylist that preceded it shipped a 94 MB zip containing
|
|
36
|
+
// node_modules, customer folders and a live JWT.)
|
|
37
|
+
const PACKET = {
|
|
38
|
+
// Folders whose contents ship. Checked at the first level under the pack
|
|
39
|
+
// root and under atris/. Anything not listed here is skipped.
|
|
40
|
+
directories: [
|
|
41
|
+
'features', // what got built, and why
|
|
42
|
+
'wiki', // ingested knowledge
|
|
43
|
+
'skills', // reusable procedures
|
|
44
|
+
'team', // member and role definitions
|
|
45
|
+
'policies', // house rules
|
|
46
|
+
'refs', // stable reference tables
|
|
47
|
+
'reference',
|
|
48
|
+
'checklists',
|
|
49
|
+
'templates',
|
|
50
|
+
'guides',
|
|
51
|
+
'docs',
|
|
52
|
+
'briefs',
|
|
53
|
+
'playbooks',
|
|
54
|
+
],
|
|
55
|
+
// Root-level files ship only as documents: the map, the readme, the persona.
|
|
56
|
+
rootExtensions: ['.md', '.markdown', '.txt'],
|
|
57
|
+
// ...plus the manifest itself.
|
|
58
|
+
rootFiles: ['pack.json'],
|
|
59
|
+
// Inside an allowed folder, only these extensions ship. Text only.
|
|
60
|
+
extensions: ['.md', '.markdown', '.txt', '.yml', '.yaml', '.json', '.toml', '.csv'],
|
|
61
|
+
// Path segments that never ship, at any depth. Runtime exhaust and deps.
|
|
62
|
+
deniedSegments: [
|
|
63
|
+
'runs', 'logs', 'journal', 'status', 'missions', 'queue', 'tmp', 'cache',
|
|
64
|
+
'.atris', '.git', '.upstream', '.venv', 'venv', 'node_modules',
|
|
65
|
+
'__pycache__', 'dist', 'build', 'coverage', '_archive',
|
|
66
|
+
// Proof folders are receipts of runs that already happened, not knowledge a
|
|
67
|
+
// stranger can use. A packet ships the feature definition, not its history.
|
|
68
|
+
// (2026-07-27: features/*/proof was 1,253 files and 76% of the unpacked
|
|
69
|
+
// weight of the atrisos-backend workspace.)
|
|
70
|
+
'proof',
|
|
71
|
+
// Same class as proof: a receipt records that a run happened. Kept as its
|
|
72
|
+
// own entry so the principle stays legible rather than reading as one
|
|
73
|
+
// grab-bag of excluded words.
|
|
74
|
+
'receipts',
|
|
75
|
+
],
|
|
76
|
+
// Segments that get their own skip reason, because "runtime exhaust" does not
|
|
77
|
+
// explain why a folder full of readable markdown was left out.
|
|
78
|
+
segmentReasons: {
|
|
79
|
+
proof: 'proof artifacts are excluded (receipts of past runs, not knowledge)',
|
|
80
|
+
receipts: 'receipts are excluded (records of past runs, not knowledge)',
|
|
81
|
+
},
|
|
82
|
+
|
|
83
|
+
// ── definitions, not state ────────────────────────────────────────────────
|
|
84
|
+
// A packet carries what a thing IS, never what it is currently DOING. The
|
|
85
|
+
// definition files travel: MEMBER.md, MISSION.md, SKILL.md, SOUL.md,
|
|
86
|
+
// README.md, wiki pages, policies. The running state a live workspace keeps
|
|
87
|
+
// beside them stays home: a stranger cannot use someone else's standup notes,
|
|
88
|
+
// and project-management exhaust is most of the file count.
|
|
89
|
+
//
|
|
90
|
+
// The rule is matched on FILENAME, not on folder path, so it generalizes to
|
|
91
|
+
// any Atris workspace instead of naming the folders of one.
|
|
92
|
+
// (2026-07-27, atrisos-backend/atris: team/ was 939 eligible files of which
|
|
93
|
+
// only ~129 were definitions; features/ was 1,183 of which only 43 were
|
|
94
|
+
// README.md. The rest were now.md/goals.md, dated journals, and the
|
|
95
|
+
// idea/build/validate plan-do-review triplet.)
|
|
96
|
+
runningStateNames: [
|
|
97
|
+
'now.md', // what this member/feature is doing right now
|
|
98
|
+
'goals.md', // this workspace's current targets
|
|
99
|
+
'idea.md', // the plan-do-review triplet: an internal tracker, not a
|
|
100
|
+
'build.md', // description of the thing. features/*/README.md is the
|
|
101
|
+
'validate.md', // definition and it still ships.
|
|
102
|
+
],
|
|
103
|
+
// Dated journal entries, at any depth: 2026-07-06.md, 2026-07-06-retro.md.
|
|
104
|
+
runningStatePatterns: [/^\d{4}-\d{2}-\d{2}(?:[-_][^.]*)?\.[a-z0-9]+$/i],
|
|
105
|
+
runningStateReason: 'running state is excluded (a pack carries definitions, not state)',
|
|
106
|
+
// Exact filenames that never ship.
|
|
107
|
+
deniedNames: [
|
|
108
|
+
'package-lock.json', 'yarn.lock', 'pnpm-lock.yaml', 'poetry.lock',
|
|
109
|
+
'cargo.lock', 'uv.lock', '.ds_store',
|
|
110
|
+
],
|
|
111
|
+
// Secret-shaped filenames that never ship, at any depth.
|
|
112
|
+
deniedPatterns: [
|
|
113
|
+
/^credentials/i, /^\.env/i, /\.pem$/i, /\.key$/i, /^id_rsa/i, /^id_ed25519/i,
|
|
114
|
+
],
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
// Credential shapes. A hit blocks the publish; the match is redacted, never
|
|
118
|
+
// echoed, because the point of the scan is to stop the secret from travelling.
|
|
119
|
+
const SECRET_PATTERNS = [
|
|
120
|
+
{ label: 'jwt', regex: /\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}/g },
|
|
121
|
+
{ label: 'api key', regex: /\b(?:sk-[A-Za-z0-9_-]{16,}|ghp_[A-Za-z0-9]{16,}|gho_[A-Za-z0-9]{16,}|github_pat_[A-Za-z0-9_]{20,}|AKIA[0-9A-Z]{16})/g },
|
|
122
|
+
{ label: 'private key', regex: /-----BEGIN (?:[A-Z0-9 ]+ )?PRIVATE KEY-----/g },
|
|
123
|
+
{ label: 'database url with password', regex: /\b(?:postgres(?:ql)?|mysql):\/\/[^\s:/@]+:[^\s/@]{3,}@\S+/g },
|
|
124
|
+
{ label: 'inline credential', regex: /\b(?:api[_-]?key|secret|token|password)\s*[:=]\s*(\S{12,})/gi },
|
|
125
|
+
];
|
|
126
|
+
|
|
127
|
+
// Docs are full of examples. A value that is obviously a stand-in is not a leak.
|
|
128
|
+
const SECRET_PLACEHOLDER = /^(?:<|\{|\$|\.\.\.|x{6,}|\*{4,}|-{4,}|your[-_ ]|my[-_]|some[-_]|placeholder|example|redacted|changeme|dummy|fake|test[-_]|null|true|false|undefined)/i;
|
|
129
|
+
|
|
130
|
+
// The generic `token = ...` rule fires on code samples far more often than on
|
|
131
|
+
// real leaks, so a hit must also look like an opaque credential: one run of
|
|
132
|
+
// credential characters, not an expression, an env lookup, or a CONSTANT_NAME.
|
|
133
|
+
const CREDENTIAL_VALUE = /^[A-Za-z0-9_\-.+/=:~]{16,}$/;
|
|
134
|
+
const CREDENTIAL_REFERENCE = /process\.env|os\.environ|os\.getenv|getenv|ENV\[|env\.|config\.|settings\.|self\.|this\.|_el\./i;
|
|
135
|
+
const CONSTANT_NAME = /^[A-Z][A-Z0-9_]*$/;
|
|
136
|
+
const FILE_PATH_VALUE = /^[~/]|^\.{1,2}\//;
|
|
137
|
+
const ENV_ASSIGNMENT = /=(?!=*$)/;
|
|
138
|
+
|
|
139
|
+
function slugify(value, fallback = 'atris-pack') {
|
|
140
|
+
return String(value || fallback)
|
|
141
|
+
.toLowerCase()
|
|
142
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
143
|
+
.replace(/^-+|-+$/g, '') || fallback;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function titleFromSlug(slug) {
|
|
147
|
+
return String(slug || 'atris-pack')
|
|
148
|
+
.split('-')
|
|
149
|
+
.filter(Boolean)
|
|
150
|
+
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
|
|
151
|
+
.join(' ') || 'Atris Pack';
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function takeValue(args, name) {
|
|
155
|
+
const prefix = `${name}=`;
|
|
156
|
+
for (let i = 0; i < args.length; i += 1) {
|
|
157
|
+
const arg = String(args[i]);
|
|
158
|
+
if (arg.startsWith(prefix)) {
|
|
159
|
+
args.splice(i, 1);
|
|
160
|
+
return arg.slice(prefix.length);
|
|
161
|
+
}
|
|
162
|
+
if (arg === name) {
|
|
163
|
+
const value = args[i + 1];
|
|
164
|
+
if (value === undefined || String(value).startsWith('--')) {
|
|
165
|
+
throw new Error(`${name} requires a value`);
|
|
166
|
+
}
|
|
167
|
+
args.splice(i, 2);
|
|
168
|
+
return value;
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
return null;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function takeValues(args, name) {
|
|
175
|
+
const values = [];
|
|
176
|
+
while (args.some((arg) => String(arg) === name || String(arg).startsWith(`${name}=`))) {
|
|
177
|
+
values.push(takeValue(args, name));
|
|
178
|
+
}
|
|
179
|
+
return values;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function takeFlag(args, name, options = {}) {
|
|
183
|
+
const index = args.indexOf(name);
|
|
184
|
+
if (index === -1) return false;
|
|
185
|
+
const next = args[index + 1];
|
|
186
|
+
if (options.optionalValue && next !== undefined && !String(next).startsWith('--')) {
|
|
187
|
+
args.splice(index, 2);
|
|
188
|
+
return next;
|
|
189
|
+
}
|
|
190
|
+
args.splice(index, 1);
|
|
191
|
+
return true;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function parseSemver(value) {
|
|
195
|
+
const match = /^(\d+)\.(\d+)\.(\d+)$/.exec(String(value || ''));
|
|
196
|
+
if (!match) return null;
|
|
197
|
+
return match.slice(1).map((part) => Number(part));
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function bumpVersion(current, bump) {
|
|
201
|
+
const parsed = parseSemver(current);
|
|
202
|
+
if (!parsed) return '0.1.0';
|
|
203
|
+
const [major, minor, patch] = parsed;
|
|
204
|
+
if (bump === 'major') return `${major + 1}.0.0`;
|
|
205
|
+
if (bump === 'minor') return `${major}.${minor + 1}.0`;
|
|
206
|
+
return `${major}.${minor}.${patch + 1}`;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function comparePackVersions(left, right) {
|
|
210
|
+
const leftParsed = parseSemver(left);
|
|
211
|
+
const rightParsed = parseSemver(right);
|
|
212
|
+
if (!leftParsed || !rightParsed) {
|
|
213
|
+
return String(left || '') === String(right || '') ? 0 : null;
|
|
214
|
+
}
|
|
215
|
+
for (let i = 0; i < leftParsed.length; i += 1) {
|
|
216
|
+
if (leftParsed[i] > rightParsed[i]) return 1;
|
|
217
|
+
if (leftParsed[i] < rightParsed[i]) return -1;
|
|
218
|
+
}
|
|
219
|
+
return 0;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function readJson(filePath) {
|
|
223
|
+
if (!fs.existsSync(filePath)) return null;
|
|
224
|
+
try {
|
|
225
|
+
return JSON.parse(fs.readFileSync(filePath, 'utf8'));
|
|
226
|
+
} catch (error) {
|
|
227
|
+
throw new Error(`could not read ${filePath}: ${error.message}`);
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function writeJson(filePath, value) {
|
|
232
|
+
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
233
|
+
fs.writeFileSync(filePath, `${JSON.stringify(value, null, 2)}\n`);
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
function buildManifest(existing, options) {
|
|
237
|
+
const existingManifest = existing && typeof existing === 'object' ? existing : {};
|
|
238
|
+
const hasExistingManifest = existing && typeof existing === 'object';
|
|
239
|
+
const priceCents = existingManifest.priceCents;
|
|
240
|
+
if (priceCents !== undefined && (!Number.isSafeInteger(priceCents) || priceCents < 0)) {
|
|
241
|
+
throw new Error('pack.json priceCents must be a non-negative integer');
|
|
242
|
+
}
|
|
243
|
+
const slug = slugify(options.slug || existingManifest.slug || options.fallbackSlug);
|
|
244
|
+
const version = existingManifest.version
|
|
245
|
+
? bumpVersion(existingManifest.version, options.bump)
|
|
246
|
+
: '0.1.0';
|
|
247
|
+
const title = existingManifest.title || titleFromSlug(slug);
|
|
248
|
+
let visibility;
|
|
249
|
+
if (options.visibility !== null && options.visibility !== undefined) {
|
|
250
|
+
visibility = String(options.visibility);
|
|
251
|
+
} else if (Object.prototype.hasOwnProperty.call(existingManifest, 'visibility')) {
|
|
252
|
+
visibility = existingManifest.visibility;
|
|
253
|
+
} else if (!hasExistingManifest) {
|
|
254
|
+
visibility = 'unlisted';
|
|
255
|
+
}
|
|
256
|
+
if (visibility !== undefined && !PACK_VISIBILITIES.has(visibility)) {
|
|
257
|
+
throw new Error('pack visibility must be public, unlisted, or private');
|
|
258
|
+
}
|
|
259
|
+
const manifest = {
|
|
260
|
+
name: existingManifest.name || slug,
|
|
261
|
+
slug,
|
|
262
|
+
title,
|
|
263
|
+
description: existingManifest.description || `Atris pack for ${title}.`,
|
|
264
|
+
author: (options.author && String(options.author).trim()) || existingManifest.author || '',
|
|
265
|
+
tags: Array.isArray(existingManifest.tags) ? existingManifest.tags : [],
|
|
266
|
+
version,
|
|
267
|
+
versions: Array.isArray(existingManifest.versions) ? [...existingManifest.versions] : [],
|
|
268
|
+
};
|
|
269
|
+
if (visibility !== undefined) manifest.visibility = visibility;
|
|
270
|
+
if (priceCents !== undefined) manifest.priceCents = priceCents;
|
|
271
|
+
// Entry-contract and provenance fields must survive republish, or inspect's
|
|
272
|
+
// trust surface goes blank the moment a pack ships.
|
|
273
|
+
for (const key of [
|
|
274
|
+
'type', 'entrypoint', 'verifier', 'permissions', 'origin',
|
|
275
|
+
'created-in', 'createdIn', 'learned-at', 'learnedAt',
|
|
276
|
+
'source-urls', 'sourceUrls', 'sources',
|
|
277
|
+
]) {
|
|
278
|
+
if (existingManifest[key] !== undefined) manifest[key] = existingManifest[key];
|
|
279
|
+
}
|
|
280
|
+
if (manifest.permissions !== undefined) assertPackCapabilityPolicy(manifest.permissions);
|
|
281
|
+
manifest.versions.push({
|
|
282
|
+
version,
|
|
283
|
+
date: new Date().toISOString(),
|
|
284
|
+
// the registry rejects entries with empty notes, so never write one
|
|
285
|
+
notes: options.notes || 'version bump',
|
|
286
|
+
});
|
|
287
|
+
return manifest;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
function packetDirectories(includeLogs) {
|
|
291
|
+
return includeLogs ? [...PACKET.directories, 'logs', 'journal'] : PACKET.directories;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
function packetDeniedSegments(includeLogs) {
|
|
295
|
+
if (!includeLogs) return PACKET.deniedSegments;
|
|
296
|
+
return PACKET.deniedSegments.filter((segment) => segment !== 'logs' && segment !== 'journal');
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
// Definitions travel, running state stays home. Judged on the filename alone
|
|
300
|
+
// so the rule holds in any workspace, whatever its folders are called.
|
|
301
|
+
function isRunningStateName(lowerBase) {
|
|
302
|
+
if (PACKET.runningStateNames.includes(lowerBase)) return true;
|
|
303
|
+
return PACKET.runningStatePatterns.some((pattern) => pattern.test(lowerBase));
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
// Paths are judged relative to the pack root, so `atris/` is transparent: the
|
|
307
|
+
// same table decides `wiki/page.md` in a crafted pack and `atris/wiki/page.md`
|
|
308
|
+
// in a workspace publish.
|
|
309
|
+
function classifyPacketPath(relativePath, { includeLogs = false, isDirectory = false } = {}) {
|
|
310
|
+
const normalized = String(relativePath).replace(/\\/g, '/');
|
|
311
|
+
const parts = normalized.split('/').filter(Boolean);
|
|
312
|
+
const basename = parts[parts.length - 1] || '';
|
|
313
|
+
const lowerBase = basename.toLowerCase();
|
|
314
|
+
const denied = packetDeniedSegments(includeLogs);
|
|
315
|
+
|
|
316
|
+
for (const part of parts) {
|
|
317
|
+
if (denied.includes(part)) {
|
|
318
|
+
const reason = PACKET.segmentReasons[part];
|
|
319
|
+
return { ok: false, reason: reason || `runtime exhaust or dependency (${part}/)` };
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
if (PACKET.deniedNames.includes(lowerBase)) return { ok: false, reason: 'lockfile or editor junk' };
|
|
323
|
+
if (PACKET.deniedPatterns.some((pattern) => pattern.test(basename))) {
|
|
324
|
+
return { ok: false, reason: 'secret-shaped filename' };
|
|
325
|
+
}
|
|
326
|
+
if (!isDirectory && isRunningStateName(lowerBase)) {
|
|
327
|
+
return { ok: false, reason: PACKET.runningStateReason };
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
const inner = parts[0] === 'atris' ? parts.slice(1) : parts;
|
|
331
|
+
if (!inner.length) return { ok: true };
|
|
332
|
+
if (isDirectory) {
|
|
333
|
+
if (inner.length > 1) return { ok: true };
|
|
334
|
+
return packetDirectories(includeLogs).includes(inner[0])
|
|
335
|
+
? { ok: true }
|
|
336
|
+
: { ok: false, reason: `not in the pack allowlist (${inner[0]}/)` };
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
const extension = path.extname(lowerBase);
|
|
340
|
+
if (inner.length === 1) {
|
|
341
|
+
if (PACKET.rootFiles.includes(lowerBase)) return { ok: true };
|
|
342
|
+
return PACKET.rootExtensions.includes(extension)
|
|
343
|
+
? { ok: true }
|
|
344
|
+
: { ok: false, reason: 'not a root document (.md/.txt)' };
|
|
345
|
+
}
|
|
346
|
+
if (!packetDirectories(includeLogs).includes(inner[0])) {
|
|
347
|
+
return { ok: false, reason: `not in the pack allowlist (${inner[0]}/)` };
|
|
348
|
+
}
|
|
349
|
+
return PACKET.extensions.includes(extension)
|
|
350
|
+
? { ok: true }
|
|
351
|
+
: { ok: false, reason: `not a text file type (${extension || 'no extension'})` };
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
// The registry rejects non-UTF-8 members, and a stranger cannot read a binary
|
|
355
|
+
// anyway, so prove it decodes before shipping it.
|
|
356
|
+
function isUtf8Text(buffer) {
|
|
357
|
+
if (buffer.includes(0)) return false;
|
|
358
|
+
return Buffer.compare(Buffer.from(buffer.toString('utf8'), 'utf8'), buffer) === 0;
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
// A crafted pack keeps wiki/, README.md, and friends at the pack root, so a
|
|
362
|
+
// root publish walks the whole folder, not just atris/ (2026-07-09: the first
|
|
363
|
+
// dogfooded pack shipped without its research).
|
|
364
|
+
function collectPacketEntries(sourceDir, { prefix = '', includeLogs = false } = {}) {
|
|
365
|
+
const entries = [];
|
|
366
|
+
const skipped = [];
|
|
367
|
+
|
|
368
|
+
function record(relative, reason) {
|
|
369
|
+
skipped.push({ path: prefix ? `${prefix}/${relative}` : relative, reason });
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
function walk(dir, relativeDir = '') {
|
|
373
|
+
const names = fs.readdirSync(dir).sort();
|
|
374
|
+
for (const name of names) {
|
|
375
|
+
const abs = path.join(dir, name);
|
|
376
|
+
const rel = (relativeDir ? `${relativeDir}/${name}` : name).replace(/\\/g, '/');
|
|
377
|
+
const scoped = prefix ? `${prefix}/${rel}` : rel;
|
|
378
|
+
let stat;
|
|
379
|
+
try {
|
|
380
|
+
stat = fs.statSync(abs);
|
|
381
|
+
} catch {
|
|
382
|
+
record(rel, 'unreadable');
|
|
383
|
+
continue;
|
|
384
|
+
}
|
|
385
|
+
if (stat.isDirectory()) {
|
|
386
|
+
const verdict = classifyPacketPath(scoped, { includeLogs, isDirectory: true });
|
|
387
|
+
if (!verdict.ok) {
|
|
388
|
+
record(`${rel}/`, verdict.reason);
|
|
389
|
+
continue;
|
|
390
|
+
}
|
|
391
|
+
walk(abs, rel);
|
|
392
|
+
continue;
|
|
393
|
+
}
|
|
394
|
+
if (!stat.isFile()) continue;
|
|
395
|
+
const verdict = classifyPacketPath(scoped, { includeLogs });
|
|
396
|
+
if (!verdict.ok) {
|
|
397
|
+
record(rel, verdict.reason);
|
|
398
|
+
continue;
|
|
399
|
+
}
|
|
400
|
+
const data = fs.readFileSync(abs);
|
|
401
|
+
if (!isUtf8Text(data)) {
|
|
402
|
+
record(rel, 'not valid utf-8 text');
|
|
403
|
+
continue;
|
|
404
|
+
}
|
|
405
|
+
entries.push({ name: scoped, data, mtime: stat.mtime });
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
walk(sourceDir);
|
|
410
|
+
return { entries, skipped };
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
function sha256(data) {
|
|
414
|
+
return createHash('sha256').update(data).digest('hex');
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
function canonicalZipEntryName(name) {
|
|
418
|
+
return path.posix.normalize(String(name || '').replace(/\\/g, '/'));
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
function canonicalContentPath(value) {
|
|
422
|
+
const raw = String(value || '');
|
|
423
|
+
if (!raw || raw.includes('\\') || raw.startsWith('/') || raw.endsWith('/')) return null;
|
|
424
|
+
const normalized = path.posix.normalize(raw);
|
|
425
|
+
if (normalized !== raw || normalized === '.' || normalized === 'pack.json') return null;
|
|
426
|
+
if (normalized.split('/').includes('..')) return null;
|
|
427
|
+
return normalized;
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
function contentEntries(entries) {
|
|
431
|
+
return entries.filter((entry) => {
|
|
432
|
+
const raw = String(entry.name || '').replace(/\\/g, '/');
|
|
433
|
+
return raw && !raw.endsWith('/') && canonicalZipEntryName(raw) !== 'pack.json';
|
|
434
|
+
});
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
function buildContentHashes(entries) {
|
|
438
|
+
return Object.fromEntries(
|
|
439
|
+
contentEntries(entries)
|
|
440
|
+
.map((entry) => [canonicalZipEntryName(entry.name), sha256(entry.data)])
|
|
441
|
+
.sort(([left], [right]) => left.localeCompare(right)),
|
|
442
|
+
);
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
function parseContentHashes(manifest) {
|
|
446
|
+
if (!Object.prototype.hasOwnProperty.call(manifest, 'content-hashes')) {
|
|
447
|
+
return { present: false, hashes: new Map() };
|
|
448
|
+
}
|
|
449
|
+
const declared = manifest['content-hashes'];
|
|
450
|
+
if (!declared || typeof declared !== 'object' || Array.isArray(declared)) {
|
|
451
|
+
throw new Error('pack.json content-hashes must be an object of path to lowercase SHA-256');
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
const hashes = new Map();
|
|
455
|
+
for (const [rawPath, digest] of Object.entries(declared)) {
|
|
456
|
+
const contentPath = canonicalContentPath(rawPath);
|
|
457
|
+
if (!contentPath) {
|
|
458
|
+
throw new Error(`pack.json content-hashes has invalid path: ${rawPath}`);
|
|
459
|
+
}
|
|
460
|
+
if (typeof digest !== 'string' || !/^[0-9a-f]{64}$/.test(digest)) {
|
|
461
|
+
throw new Error(`pack.json content-hashes has invalid SHA-256 for ${rawPath}`);
|
|
462
|
+
}
|
|
463
|
+
hashes.set(contentPath, digest);
|
|
464
|
+
}
|
|
465
|
+
return { present: true, hashes };
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
function verifyArchiveContentHashes(entries, manifest) {
|
|
469
|
+
const declared = parseContentHashes(manifest);
|
|
470
|
+
if (declared.present) {
|
|
471
|
+
for (const entry of entries) {
|
|
472
|
+
const raw = String(entry.name || '');
|
|
473
|
+
if (!raw.endsWith('/') && (raw.includes('\\') || canonicalZipEntryName(raw) !== raw)) {
|
|
474
|
+
throw new Error(`pack content-hashes requires canonical archive path: ${raw}`);
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
const files = new Map(
|
|
479
|
+
contentEntries(entries).map((entry) => [canonicalZipEntryName(entry.name), entry.data]),
|
|
480
|
+
);
|
|
481
|
+
if (!declared.present) {
|
|
482
|
+
return { status: 'absent', declared: 0, files: files.size, hashes: declared.hashes };
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
for (const [contentPath, digest] of declared.hashes) {
|
|
486
|
+
const data = files.get(contentPath);
|
|
487
|
+
if (!data) throw new Error(`pack content-hashes claims missing file: ${contentPath}`);
|
|
488
|
+
if (sha256(data) !== digest) {
|
|
489
|
+
throw new Error(`pack content hash mismatch: ${contentPath}`);
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
return {
|
|
493
|
+
status: declared.hashes.size === files.size ? 'verified' : 'partial',
|
|
494
|
+
declared: declared.hashes.size,
|
|
495
|
+
files: files.size,
|
|
496
|
+
hashes: declared.hashes,
|
|
497
|
+
};
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
function contentHashFingerprint(manifest) {
|
|
501
|
+
const parsed = parseContentHashes(manifest);
|
|
502
|
+
if (!parsed.present) return null;
|
|
503
|
+
return JSON.stringify([...parsed.hashes.entries()].sort(([left], [right]) => left.localeCompare(right)));
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
function redactSecret(value) {
|
|
507
|
+
const text = String(value || '');
|
|
508
|
+
if (text.length <= 8) return '*'.repeat(Math.max(text.length, 3));
|
|
509
|
+
const masked = '*'.repeat(Math.min(12, text.length - 6));
|
|
510
|
+
return `${text.slice(0, 4)}${masked}${text.slice(-2)}`;
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
function trimSecretValue(value) {
|
|
514
|
+
return String(value || '')
|
|
515
|
+
.replace(/^[`'"]+/, '')
|
|
516
|
+
.replace(/[`'"]+$/, '')
|
|
517
|
+
.replace(/[,;.)\]}>|]+$/, '');
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
function looksLikePlaceholder(value) {
|
|
521
|
+
return SECRET_PLACEHOLDER.test(trimSecretValue(value));
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
function looksLikeCredentialValue(value) {
|
|
525
|
+
const trimmed = trimSecretValue(value);
|
|
526
|
+
if (!CREDENTIAL_VALUE.test(trimmed)) return false;
|
|
527
|
+
if (CREDENTIAL_REFERENCE.test(trimmed)) return false;
|
|
528
|
+
if (CONSTANT_NAME.test(trimmed)) return false;
|
|
529
|
+
if (FILE_PATH_VALUE.test(trimmed)) return false;
|
|
530
|
+
if (ENV_ASSIGNMENT.test(trimmed)) return false;
|
|
531
|
+
// Real credentials carry entropy: a digit, or at least mixed case.
|
|
532
|
+
return /\d/.test(trimmed) || (/[a-z]/.test(trimmed) && /[A-Z]/.test(trimmed));
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
function scanTextForSecrets(text) {
|
|
536
|
+
const findings = [];
|
|
537
|
+
const lines = String(text).split(/\r?\n/);
|
|
538
|
+
for (let i = 0; i < lines.length; i += 1) {
|
|
539
|
+
const line = lines[i];
|
|
540
|
+
for (const pattern of SECRET_PATTERNS) {
|
|
541
|
+
pattern.regex.lastIndex = 0;
|
|
542
|
+
let match = pattern.regex.exec(line);
|
|
543
|
+
while (match) {
|
|
544
|
+
const value = match[1] !== undefined ? match[1] : match[0];
|
|
545
|
+
// The shaped patterns (jwt, sk-, AKIA, BEGIN PRIVATE KEY) are specific
|
|
546
|
+
// enough to stand on their own; only the generic rule needs filtering.
|
|
547
|
+
const generic = match[1] !== undefined;
|
|
548
|
+
if (!generic || (!looksLikePlaceholder(value) && looksLikeCredentialValue(value))) {
|
|
549
|
+
findings.push({ line: i + 1, label: pattern.label, redacted: redactSecret(trimSecretValue(value)) });
|
|
550
|
+
}
|
|
551
|
+
match = pattern.regex.exec(line);
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
}
|
|
555
|
+
return findings;
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
function scanEntriesForSecrets(entries) {
|
|
559
|
+
const findings = [];
|
|
560
|
+
for (const entry of entries) {
|
|
561
|
+
if (!entry || !entry.data) continue;
|
|
562
|
+
for (const hit of scanTextForSecrets(entry.data.toString('utf8'))) {
|
|
563
|
+
findings.push({ file: entry.name, ...hit });
|
|
564
|
+
}
|
|
565
|
+
}
|
|
566
|
+
return findings;
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
function parseJsonBody(buffer) {
|
|
570
|
+
const text = Buffer.isBuffer(buffer) ? buffer.toString('utf8') : String(buffer || '');
|
|
571
|
+
if (!text.trim()) return { data: null, text };
|
|
572
|
+
try {
|
|
573
|
+
return { data: JSON.parse(text), text };
|
|
574
|
+
} catch {
|
|
575
|
+
return { data: null, text };
|
|
576
|
+
}
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
function responseErrorText(response, fallback) {
|
|
580
|
+
const parsed = parseJsonBody(response.body);
|
|
581
|
+
const data = parsed.data;
|
|
582
|
+
if (data && typeof data === 'object') {
|
|
583
|
+
const message = data.error || data.message || data.detail;
|
|
584
|
+
if (message) return String(message);
|
|
585
|
+
}
|
|
586
|
+
if (parsed.text.trim()) return parsed.text.trim();
|
|
587
|
+
return fallback;
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
function registryUrl(pathname, deps = {}) {
|
|
591
|
+
const appBase = (deps.getAppBaseUrl || getAppBaseUrl)();
|
|
592
|
+
const cleanBase = String(appBase || '').replace(/\/+$/, '');
|
|
593
|
+
return `${cleanBase}${pathname}`;
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
// The web app's CSRF gate only trusts requests whose Origin matches its own
|
|
597
|
+
// url; bearer-token CLI posts are not a CSRF vector, so we present the app
|
|
598
|
+
// origin we are posting to (2026-07-09: prod publish --push failed 403 while
|
|
599
|
+
// every test passed against a bare local server).
|
|
600
|
+
function registryOrigin(deps = {}) {
|
|
601
|
+
const appBase = (deps.getAppBaseUrl || getAppBaseUrl)();
|
|
602
|
+
try {
|
|
603
|
+
return new URL(String(appBase)).origin;
|
|
604
|
+
} catch {
|
|
605
|
+
return '';
|
|
606
|
+
}
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
function optionalAuthHeaders(deps = {}) {
|
|
610
|
+
const readCredentials = deps.loadCredentials || loadCredentials;
|
|
611
|
+
const credentials = readCredentials();
|
|
612
|
+
return credentials && credentials.token ? { Authorization: `Bearer ${credentials.token}` } : {};
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
function refreshApiWithoutProviderHint(deps = {}) {
|
|
616
|
+
const requestJson = deps.apiRequestJson || apiRequestJson;
|
|
617
|
+
return (pathname, options = {}) => {
|
|
618
|
+
if (pathname !== '/auth/refresh') return requestJson(pathname, options);
|
|
619
|
+
const body = options.body && typeof options.body === 'object' ? { ...options.body } : {};
|
|
620
|
+
delete body.provider;
|
|
621
|
+
return requestJson(pathname, { ...options, body });
|
|
622
|
+
};
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
async function refreshRegistryCredentials(credentials, deps = {}) {
|
|
626
|
+
const refresh = deps.performTokenRefresh || performTokenRefresh;
|
|
627
|
+
try {
|
|
628
|
+
const result = await refresh(credentials, refreshApiWithoutProviderHint(deps));
|
|
629
|
+
if (!result || !result.ok) return null;
|
|
630
|
+
return result.payload?.credentials || (deps.loadCredentials || loadCredentials)();
|
|
631
|
+
} catch {
|
|
632
|
+
return null;
|
|
633
|
+
}
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
async function requestRegistryZip(url, deps = {}, options = {}) {
|
|
637
|
+
const request = deps.httpRequest || httpRequest;
|
|
638
|
+
const readCredentials = deps.loadCredentials || loadCredentials;
|
|
639
|
+
const credentials = readCredentials();
|
|
640
|
+
const authenticated = Boolean(credentials && credentials.token);
|
|
641
|
+
const requestWithToken = (token) => request(url, {
|
|
642
|
+
...options,
|
|
643
|
+
method: 'GET',
|
|
644
|
+
headers: token ? { ...(options.headers || {}), Authorization: `Bearer ${token}` } : { ...(options.headers || {}) },
|
|
645
|
+
});
|
|
646
|
+
|
|
647
|
+
let response = await requestWithToken(credentials?.token);
|
|
648
|
+
if (response.status === 404 && authenticated) {
|
|
649
|
+
const refreshedCredentials = await refreshRegistryCredentials(credentials, deps);
|
|
650
|
+
if (refreshedCredentials && refreshedCredentials.token) {
|
|
651
|
+
response = await requestWithToken(refreshedCredentials.token);
|
|
652
|
+
}
|
|
653
|
+
}
|
|
654
|
+
return { response, authenticated };
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
function registryNotFoundError(slug, authenticated) {
|
|
658
|
+
if (authenticated) {
|
|
659
|
+
return new Error('pack not found or you do not have access (your login may be stale; try atris login)');
|
|
660
|
+
}
|
|
661
|
+
return new Error(`pack not found: ${slug}`);
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
function requiredAuthHeaders(deps = {}, purpose = 'publish packs') {
|
|
665
|
+
const headers = optionalAuthHeaders(deps);
|
|
666
|
+
if (!headers.Authorization) {
|
|
667
|
+
throw new Error(`not logged in. run atris login first to ${purpose}.`);
|
|
668
|
+
}
|
|
669
|
+
return headers;
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
function assertPublishableSlug(slug) {
|
|
673
|
+
if (!SLUG_RULE.test(String(slug || ''))) {
|
|
674
|
+
throw new Error(
|
|
675
|
+
`pack slug "${slug}" is not viewable on the web. use 3-40 characters of a-z, 0-9 and dashes: --slug <slug>`,
|
|
676
|
+
);
|
|
677
|
+
}
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
function assertPublishableAuthor(manifest) {
|
|
681
|
+
if (!manifest.author || !String(manifest.author).trim()) {
|
|
682
|
+
throw new Error('publishing needs an author. re-run with --author "<your name>" (or set "author" in pack.json).');
|
|
683
|
+
}
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
async function postPackToRegistry(manifest, zipBuffer, deps = {}) {
|
|
687
|
+
const request = deps.httpRequest || httpRequest;
|
|
688
|
+
const url = registryUrl('/api/pack/registry', deps);
|
|
689
|
+
const body = JSON.stringify({ manifest, zipBase64: zipBuffer.toString('base64') });
|
|
690
|
+
const authHeaders = requiredAuthHeaders(deps);
|
|
691
|
+
assertPublishableAuthor(manifest);
|
|
692
|
+
let response;
|
|
693
|
+
try {
|
|
694
|
+
response = await request(url, {
|
|
695
|
+
method: 'POST',
|
|
696
|
+
timeoutMs: REGISTRY_TIMEOUT_MS,
|
|
697
|
+
headers: {
|
|
698
|
+
...authHeaders,
|
|
699
|
+
Accept: 'application/json',
|
|
700
|
+
'Content-Type': 'application/json',
|
|
701
|
+
...(registryOrigin(deps) ? { Origin: registryOrigin(deps) } : {}),
|
|
702
|
+
},
|
|
703
|
+
body,
|
|
704
|
+
});
|
|
705
|
+
} catch {
|
|
706
|
+
throw new Error('could not reach pack registry. check your connection and try again.');
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
const parsed = parseJsonBody(response.body);
|
|
710
|
+
if (response.status < 200 || response.status >= 300) {
|
|
711
|
+
throw new Error(responseErrorText(response, `registry publish failed with status ${response.status}`));
|
|
712
|
+
}
|
|
713
|
+
if (!parsed.data || typeof parsed.data !== 'object' || parsed.data.ok === false) {
|
|
714
|
+
throw new Error(responseErrorText(response, 'registry publish failed'));
|
|
715
|
+
}
|
|
716
|
+
return parsed.data;
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
function paidPackBarrierFromResponse(response, slug) {
|
|
720
|
+
const status = Number(response && response.status) || 0;
|
|
721
|
+
if (status === 404) return null;
|
|
722
|
+
|
|
723
|
+
const rawBody = response && response.body;
|
|
724
|
+
const body = !rawBody
|
|
725
|
+
? Buffer.alloc(0)
|
|
726
|
+
: (Buffer.isBuffer(rawBody) ? rawBody : Buffer.from(String(rawBody)));
|
|
727
|
+
const empty = body.length === 0;
|
|
728
|
+
const parsed = parseJsonBody(body);
|
|
729
|
+
const data = parsed.data && typeof parsed.data === 'object' && !Array.isArray(parsed.data)
|
|
730
|
+
? parsed.data
|
|
731
|
+
: {};
|
|
732
|
+
const priceRaw = data.price_cents ?? data.priceCents;
|
|
733
|
+
const priceCents = Number(priceRaw);
|
|
734
|
+
const hasPrice = Number.isFinite(priceCents) && priceCents > 0;
|
|
735
|
+
const nameSource = [data.name, data.title, data.slug, slug]
|
|
736
|
+
.find((value) => typeof value === 'string' && value.trim());
|
|
737
|
+
const details = {
|
|
738
|
+
name: String(nameSource || slug || 'this pack').trim().toLowerCase(),
|
|
739
|
+
priceCents: hasPrice ? Math.round(priceCents) : null,
|
|
740
|
+
};
|
|
741
|
+
|
|
742
|
+
const entitled = data.entitled === true
|
|
743
|
+
|| data.has_access === true
|
|
744
|
+
|| data.hasAccess === true
|
|
745
|
+
|| data.is_owner === true
|
|
746
|
+
|| data.isOwner === true;
|
|
747
|
+
const unentitled = data.entitled === false
|
|
748
|
+
|| data.has_access === false
|
|
749
|
+
|| data.hasAccess === false
|
|
750
|
+
|| data.entitlement === false
|
|
751
|
+
|| data.entitlement === null;
|
|
752
|
+
const message = String(data.error || data.message || data.detail || '');
|
|
753
|
+
const looksLikeZip = body.length >= 4 && body[0] === 0x50 && body[1] === 0x4b;
|
|
754
|
+
const paid = status === 402
|
|
755
|
+
|| data.paid === true
|
|
756
|
+
|| data.is_paid === true
|
|
757
|
+
|| data.isPaid === true
|
|
758
|
+
|| data.purchase_required === true
|
|
759
|
+
|| typeof data.checkout_hint === 'string'
|
|
760
|
+
|| /purchase required/i.test(message)
|
|
761
|
+
|| (hasPrice && (unentitled || !looksLikeZip));
|
|
762
|
+
|
|
763
|
+
if (entitled && status >= 200 && status < 300 && looksLikeZip) return null;
|
|
764
|
+
if (status === 402 || status === 403) return details;
|
|
765
|
+
if (status >= 200 && status < 300 && empty) return details;
|
|
766
|
+
if (paid && !entitled && !looksLikeZip) return details;
|
|
767
|
+
return null;
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
function formatSalesDollars(priceCents) {
|
|
771
|
+
const cents = Math.round(Number(priceCents));
|
|
772
|
+
if (!Number.isFinite(cents)) return '$0';
|
|
773
|
+
const sign = cents < 0 ? '-' : '';
|
|
774
|
+
const absoluteCents = Math.abs(cents);
|
|
775
|
+
const dollars = Math.floor(absoluteCents / 100).toLocaleString('en-US');
|
|
776
|
+
const remainder = absoluteCents % 100;
|
|
777
|
+
return `${sign}$${dollars}${remainder ? `.${String(remainder).padStart(2, '0')}` : ''}`;
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
function printPaidPackBarrier(slug, details = {}, deps = {}) {
|
|
781
|
+
const name = String(details.name || slug || 'this pack').trim().toLowerCase() || 'this pack';
|
|
782
|
+
const cents = Number(details.priceCents);
|
|
783
|
+
const price = Number.isFinite(cents) && cents > 0 ? ` (${formatSalesDollars(cents)})` : '';
|
|
784
|
+
console.error(`this pack is paid: ${name}${price}.`);
|
|
785
|
+
console.error('buy it on its page, then run this again.');
|
|
786
|
+
console.error(registryUrl(`/packs/${encodeURIComponent(slug)}`, deps));
|
|
787
|
+
console.error('already bought it? run atris login');
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
function throwPaidPackBarrier(barrier, status) {
|
|
791
|
+
const error = new Error('this pack is paid');
|
|
792
|
+
error.status = status;
|
|
793
|
+
error.paidPack = barrier;
|
|
794
|
+
throw error;
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
async function fetchRegistryZip(slug, deps = {}) {
|
|
798
|
+
const url = registryUrl(`/api/pack/registry/${encodeURIComponent(slug)}`, deps);
|
|
799
|
+
let result;
|
|
800
|
+
try {
|
|
801
|
+
result = await requestRegistryZip(url, deps, {
|
|
802
|
+
timeoutMs: REGISTRY_TIMEOUT_MS,
|
|
803
|
+
});
|
|
804
|
+
} catch {
|
|
805
|
+
throw new Error('could not reach pack registry. check your connection and try again.');
|
|
806
|
+
}
|
|
807
|
+
const { response, authenticated } = result;
|
|
808
|
+
if (response.status === 404) throw registryNotFoundError(slug, authenticated);
|
|
809
|
+
const barrier = paidPackBarrierFromResponse(response, slug);
|
|
810
|
+
if (barrier) throwPaidPackBarrier(barrier, response.status);
|
|
811
|
+
if (response.status < 200 || response.status >= 300) {
|
|
812
|
+
const error = new Error(responseErrorText(response, `registry lookup failed for ${slug} with status ${response.status}`));
|
|
813
|
+
error.status = response.status;
|
|
814
|
+
throw error;
|
|
815
|
+
}
|
|
816
|
+
if (!response.body || response.body.length === 0) {
|
|
817
|
+
throw new Error(`registry returned an empty zip for ${slug}`);
|
|
818
|
+
}
|
|
819
|
+
return response.body;
|
|
820
|
+
}
|
|
821
|
+
|
|
822
|
+
async function fetchRegistryZipForUser(slug, deps = {}) {
|
|
823
|
+
try {
|
|
824
|
+
return await fetchRegistryZip(slug, deps);
|
|
825
|
+
} catch (error) {
|
|
826
|
+
if (!error || !error.paidPack) throw error;
|
|
827
|
+
printPaidPackBarrier(slug, error.paidPack, deps);
|
|
828
|
+
return null;
|
|
829
|
+
}
|
|
830
|
+
}
|
|
831
|
+
|
|
832
|
+
// ── pack share ──────────────────────────────────────────────────────────────
|
|
833
|
+
// Signed links are the primary handoff. The old /packs/<slug>?for=<name> link
|
|
834
|
+
// is retained only when minting fails for a pack the public registry confirms
|
|
835
|
+
// is public.
|
|
836
|
+
const PERSONALIZATION_MAX_LENGTH = 40;
|
|
837
|
+
const PERSONALIZATION_MARKUP = /[<>&"`\\/]/;
|
|
838
|
+
const PERSONALIZATION_DISALLOWED = /[^\p{L}\p{M} '’.\-]/gu;
|
|
839
|
+
const PERSONALIZATION_HAS_LETTER = /\p{L}/u;
|
|
840
|
+
|
|
841
|
+
function sanitizePersonalizationName(raw) {
|
|
842
|
+
if (typeof raw !== 'string') return null;
|
|
843
|
+
if (PERSONALIZATION_MARKUP.test(raw)) return null;
|
|
844
|
+
const cleaned = raw
|
|
845
|
+
.normalize('NFC')
|
|
846
|
+
.replace(/\s/g, ' ')
|
|
847
|
+
.replace(PERSONALIZATION_DISALLOWED, '')
|
|
848
|
+
.replace(/ +/g, ' ')
|
|
849
|
+
.trim();
|
|
850
|
+
if (!cleaned) return null;
|
|
851
|
+
const capped = cleaned.slice(0, PERSONALIZATION_MAX_LENGTH).trim();
|
|
852
|
+
if (!capped || !PERSONALIZATION_HAS_LETTER.test(capped)) return null;
|
|
853
|
+
return capped;
|
|
854
|
+
}
|
|
855
|
+
|
|
856
|
+
function parsePackShareArgs(rawArgs) {
|
|
857
|
+
const args = [...rawArgs];
|
|
858
|
+
const forName = takeValue(args, '--for');
|
|
859
|
+
const daysValue = takeValue(args, '--days');
|
|
860
|
+
const list = takeFlag(args, '--list');
|
|
861
|
+
const revoke = takeFlag(args, '--revoke', { optionalValue: true });
|
|
862
|
+
const slug = args.shift();
|
|
863
|
+
if (!slug) throw new Error('pack share needs a slug: atris pack share <slug> --for "<Name>"');
|
|
864
|
+
if (args.length) throw new Error(`unknown pack share argument: ${args.join(' ')}`);
|
|
865
|
+
assertPublishableSlug(slug);
|
|
866
|
+
|
|
867
|
+
if (list) {
|
|
868
|
+
if (forName !== null || daysValue !== null || revoke !== false) {
|
|
869
|
+
throw new Error('pack share --list cannot be combined with --for, --days, or --revoke');
|
|
870
|
+
}
|
|
871
|
+
return { mode: 'list', slug };
|
|
872
|
+
}
|
|
873
|
+
|
|
874
|
+
if (revoke !== false) {
|
|
875
|
+
if (forName !== null || daysValue !== null) {
|
|
876
|
+
throw new Error('pack share --revoke cannot be combined with --for or --days');
|
|
877
|
+
}
|
|
878
|
+
if (typeof revoke === 'string' && !SHARE_NONCE_RULE.test(revoke)) {
|
|
879
|
+
throw new Error('--revoke link id must be a 22-character base64url value');
|
|
880
|
+
}
|
|
881
|
+
return {
|
|
882
|
+
mode: 'revoke',
|
|
883
|
+
slug,
|
|
884
|
+
...(typeof revoke === 'string' ? { nonce: revoke } : {}),
|
|
885
|
+
};
|
|
886
|
+
}
|
|
887
|
+
|
|
888
|
+
if (forName === null) {
|
|
889
|
+
if (daysValue !== null) throw new Error('pack share --days requires --for "<Name>"');
|
|
890
|
+
return { mode: 'plain', slug };
|
|
891
|
+
}
|
|
892
|
+
|
|
893
|
+
const recipientLabel = String(forName).trim();
|
|
894
|
+
if (!recipientLabel || recipientLabel.length > MAX_SHARE_RECIPIENT_LENGTH) {
|
|
895
|
+
throw new Error(
|
|
896
|
+
`--for needs a recipient label between 1 and ${MAX_SHARE_RECIPIENT_LENGTH} characters`,
|
|
897
|
+
);
|
|
898
|
+
}
|
|
899
|
+
|
|
900
|
+
const expiresInDays = daysValue === null ? DEFAULT_SHARE_DAYS : Number(daysValue);
|
|
901
|
+
if (!Number.isSafeInteger(expiresInDays) || expiresInDays < 1 || expiresInDays > MAX_SHARE_DAYS) {
|
|
902
|
+
throw new Error(`--days must be a whole number from 1 to ${MAX_SHARE_DAYS}`);
|
|
903
|
+
}
|
|
904
|
+
return { mode: 'mint', slug, recipientLabel, expiresInDays };
|
|
905
|
+
}
|
|
906
|
+
|
|
907
|
+
async function requestRegistryJson(pathname, requestOptions = {}, deps = {}) {
|
|
908
|
+
const request = deps.httpRequest || httpRequest;
|
|
909
|
+
const method = requestOptions.method || 'GET';
|
|
910
|
+
const headers = {
|
|
911
|
+
Accept: 'application/json',
|
|
912
|
+
...(requestOptions.authPurpose
|
|
913
|
+
? requiredAuthHeaders(deps, requestOptions.authPurpose)
|
|
914
|
+
: {}),
|
|
915
|
+
};
|
|
916
|
+
if (requestOptions.sendOrigin) {
|
|
917
|
+
const origin = registryOrigin(deps);
|
|
918
|
+
if (origin) headers.Origin = origin;
|
|
919
|
+
}
|
|
920
|
+
|
|
921
|
+
let body;
|
|
922
|
+
if (requestOptions.body !== undefined) {
|
|
923
|
+
headers['Content-Type'] = 'application/json';
|
|
924
|
+
body = JSON.stringify(requestOptions.body);
|
|
925
|
+
}
|
|
926
|
+
|
|
927
|
+
let response;
|
|
928
|
+
try {
|
|
929
|
+
response = await request(registryUrl(pathname, deps), {
|
|
930
|
+
method,
|
|
931
|
+
timeoutMs: REGISTRY_TIMEOUT_MS,
|
|
932
|
+
headers,
|
|
933
|
+
...(body !== undefined ? { body } : {}),
|
|
934
|
+
});
|
|
935
|
+
} catch {
|
|
936
|
+
throw new Error(requestOptions.unreachableMessage || 'could not reach pack registry. check your connection and try again.');
|
|
937
|
+
}
|
|
938
|
+
|
|
939
|
+
if (response.status < 200 || response.status >= 300) {
|
|
940
|
+
const error = new Error(responseErrorText(response, `registry request failed with status ${response.status}`));
|
|
941
|
+
error.status = response.status;
|
|
942
|
+
throw error;
|
|
943
|
+
}
|
|
944
|
+
const parsed = parseJsonBody(response.body);
|
|
945
|
+
if (!parsed.data || typeof parsed.data !== 'object') {
|
|
946
|
+
throw new Error(requestOptions.invalidMessage || 'pack registry returned an invalid response');
|
|
947
|
+
}
|
|
948
|
+
return parsed.data;
|
|
949
|
+
}
|
|
950
|
+
|
|
951
|
+
async function mintPackShare(slug, recipientLabel, expiresInDays, deps = {}) {
|
|
952
|
+
const data = await requestRegistryJson(
|
|
953
|
+
`/api/pack/registry/${encodeURIComponent(slug)}/share`,
|
|
954
|
+
{
|
|
955
|
+
method: 'POST',
|
|
956
|
+
authPurpose: 'share packs',
|
|
957
|
+
sendOrigin: true,
|
|
958
|
+
body: { recipientLabel, expiresInDays },
|
|
959
|
+
unreachableMessage: 'could not mint a pack share link. check your connection and try again.',
|
|
960
|
+
invalidMessage: 'pack registry did not return a share link',
|
|
961
|
+
},
|
|
962
|
+
deps,
|
|
963
|
+
);
|
|
964
|
+
if (typeof data.shareUrl !== 'string' || !data.shareUrl.trim()) {
|
|
965
|
+
throw new Error('pack registry did not return a share link');
|
|
966
|
+
}
|
|
967
|
+
if (!Number.isFinite(Number(data.expiresAt))) {
|
|
968
|
+
throw new Error('pack registry did not return the share link expiry');
|
|
969
|
+
}
|
|
970
|
+
return {
|
|
971
|
+
shareUrl: data.shareUrl,
|
|
972
|
+
expiresAt: Number(data.expiresAt),
|
|
973
|
+
...(typeof data.nonce === 'string' && SHARE_NONCE_RULE.test(data.nonce)
|
|
974
|
+
? { nonce: data.nonce }
|
|
975
|
+
: {}),
|
|
976
|
+
};
|
|
977
|
+
}
|
|
978
|
+
|
|
979
|
+
async function listPackShares(slug, deps = {}) {
|
|
980
|
+
const data = await requestRegistryJson(
|
|
981
|
+
`/api/pack/registry/${encodeURIComponent(slug)}/share`,
|
|
982
|
+
{
|
|
983
|
+
authPurpose: 'list personal links for packs',
|
|
984
|
+
unreachableMessage: 'could not list pack share links. check your connection and try again.',
|
|
985
|
+
invalidMessage: 'pack share links returned an invalid response',
|
|
986
|
+
},
|
|
987
|
+
deps,
|
|
988
|
+
);
|
|
989
|
+
if (!Array.isArray(data.links)) {
|
|
990
|
+
throw new Error('pack share links returned an invalid response');
|
|
991
|
+
}
|
|
992
|
+
return data.links;
|
|
993
|
+
}
|
|
994
|
+
|
|
995
|
+
async function revokePackShares(slug, deps = {}) {
|
|
996
|
+
return requestRegistryJson(
|
|
997
|
+
`/api/pack/registry/${encodeURIComponent(slug)}/share`,
|
|
998
|
+
{
|
|
999
|
+
method: 'DELETE',
|
|
1000
|
+
authPurpose: 'revoke personal links for packs',
|
|
1001
|
+
sendOrigin: true,
|
|
1002
|
+
unreachableMessage: 'could not revoke pack share links. check your connection and try again.',
|
|
1003
|
+
},
|
|
1004
|
+
deps,
|
|
1005
|
+
);
|
|
1006
|
+
}
|
|
1007
|
+
|
|
1008
|
+
async function revokePackShare(slug, nonce, deps = {}) {
|
|
1009
|
+
return requestRegistryJson(
|
|
1010
|
+
`/api/pack/registry/${encodeURIComponent(slug)}/share`,
|
|
1011
|
+
{
|
|
1012
|
+
method: 'DELETE',
|
|
1013
|
+
authPurpose: 'revoke a personal link for a pack',
|
|
1014
|
+
sendOrigin: true,
|
|
1015
|
+
body: { nonce },
|
|
1016
|
+
unreachableMessage: 'could not revoke that pack share link. check your connection and try again.',
|
|
1017
|
+
},
|
|
1018
|
+
deps,
|
|
1019
|
+
);
|
|
1020
|
+
}
|
|
1021
|
+
|
|
1022
|
+
function registryPackItems(payload) {
|
|
1023
|
+
if (Array.isArray(payload)) return payload;
|
|
1024
|
+
if (payload && Array.isArray(payload.packs)) return payload.packs;
|
|
1025
|
+
return [];
|
|
1026
|
+
}
|
|
1027
|
+
|
|
1028
|
+
function packManifestFromItem(item) {
|
|
1029
|
+
if (item && item.manifest && typeof item.manifest === 'object') return item.manifest;
|
|
1030
|
+
return item && typeof item === 'object' ? item : {};
|
|
1031
|
+
}
|
|
1032
|
+
|
|
1033
|
+
function isPublicPackPayload(payload, slug) {
|
|
1034
|
+
return registryPackItems(payload).some((item) => {
|
|
1035
|
+
const manifest = packManifestFromItem(item);
|
|
1036
|
+
return manifest.slug === slug
|
|
1037
|
+
&& (manifest.visibility === undefined || manifest.visibility === 'public');
|
|
1038
|
+
});
|
|
1039
|
+
}
|
|
1040
|
+
|
|
1041
|
+
async function registryConfirmsPublicPack(slug, deps = {}) {
|
|
1042
|
+
try {
|
|
1043
|
+
const payload = await requestRegistryJson(
|
|
1044
|
+
'/api/pack/registry',
|
|
1045
|
+
{
|
|
1046
|
+
unreachableMessage: 'could not check public pack visibility',
|
|
1047
|
+
invalidMessage: 'pack registry returned an invalid public listing',
|
|
1048
|
+
},
|
|
1049
|
+
deps,
|
|
1050
|
+
);
|
|
1051
|
+
return isPublicPackPayload(payload, slug);
|
|
1052
|
+
} catch {
|
|
1053
|
+
return false;
|
|
1054
|
+
}
|
|
1055
|
+
}
|
|
1056
|
+
|
|
1057
|
+
function legacyPersonalizedShareUrl(slug, recipientLabel, deps = {}) {
|
|
1058
|
+
const name = sanitizePersonalizationName(recipientLabel);
|
|
1059
|
+
if (!name) return null;
|
|
1060
|
+
const base = registryUrl(`/packs/${encodeURIComponent(slug)}`, deps);
|
|
1061
|
+
return `${base}?for=${encodeURIComponent(name)}`;
|
|
1062
|
+
}
|
|
1063
|
+
|
|
1064
|
+
function formatShareExpiry(expiresAt) {
|
|
1065
|
+
if (expiresAt === undefined || expiresAt === null || expiresAt === '') return 'unknown';
|
|
1066
|
+
const numeric = Number(expiresAt);
|
|
1067
|
+
const date = Number.isFinite(numeric)
|
|
1068
|
+
? new Date(numeric < 1000000000000 ? numeric * 1000 : numeric)
|
|
1069
|
+
: new Date(expiresAt);
|
|
1070
|
+
if (Number.isNaN(date.getTime())) return 'unknown';
|
|
1071
|
+
return date.toISOString().replace('T', ' ').replace('.000Z', ' utc');
|
|
1072
|
+
}
|
|
1073
|
+
|
|
1074
|
+
function cleanTableCell(value, fallback, maxLength) {
|
|
1075
|
+
const clean = String(value === undefined || value === null || value === '' ? fallback : value)
|
|
1076
|
+
.replace(/\s+/g, ' ')
|
|
1077
|
+
.trim();
|
|
1078
|
+
if (clean.length <= maxLength) return clean;
|
|
1079
|
+
return `${clean.slice(0, Math.max(1, maxLength - 3))}...`;
|
|
1080
|
+
}
|
|
1081
|
+
|
|
1082
|
+
function formatPackShareLinksTable(links) {
|
|
1083
|
+
if (!Array.isArray(links) || !links.length) return '';
|
|
1084
|
+
const rows = links.map((link) => ({
|
|
1085
|
+
label: cleanTableCell(link && link.label, 'unlabeled', 40),
|
|
1086
|
+
expires: formatShareExpiry(link && link.expiresAt),
|
|
1087
|
+
state: link && link.revoked ? 'revoked' : 'active',
|
|
1088
|
+
nonce: cleanTableCell(link && link.nonce, 'unknown', 64),
|
|
1089
|
+
}));
|
|
1090
|
+
const columns = [
|
|
1091
|
+
{ key: 'label', label: 'label' },
|
|
1092
|
+
{ key: 'expires', label: 'expires' },
|
|
1093
|
+
{ key: 'state', label: 'state' },
|
|
1094
|
+
{ key: 'nonce', label: 'nonce' },
|
|
1095
|
+
];
|
|
1096
|
+
const widths = columns.map((column) => Math.max(
|
|
1097
|
+
column.label.length,
|
|
1098
|
+
...rows.map((row) => row[column.key].length),
|
|
1099
|
+
));
|
|
1100
|
+
const render = (row) => columns
|
|
1101
|
+
.map((column, index) => (
|
|
1102
|
+
index === columns.length - 1
|
|
1103
|
+
? row[column.key]
|
|
1104
|
+
: row[column.key].padEnd(widths[index])
|
|
1105
|
+
))
|
|
1106
|
+
.join(' ');
|
|
1107
|
+
return [
|
|
1108
|
+
render(Object.fromEntries(columns.map((column) => [column.key, column.label]))),
|
|
1109
|
+
...rows.map(render),
|
|
1110
|
+
].join('\n');
|
|
1111
|
+
}
|
|
1112
|
+
|
|
1113
|
+
function browseStarValue(item, payload, slug) {
|
|
1114
|
+
const manifest = packManifestFromItem(item);
|
|
1115
|
+
const summary = payload && payload.summaries && payload.summaries[slug];
|
|
1116
|
+
const mapped = payload && payload.stars && payload.stars[slug];
|
|
1117
|
+
const candidates = [
|
|
1118
|
+
item && item.stars,
|
|
1119
|
+
item && item.starCount,
|
|
1120
|
+
manifest.stars,
|
|
1121
|
+
manifest.starCount,
|
|
1122
|
+
summary && summary.stars,
|
|
1123
|
+
summary && summary.starCount,
|
|
1124
|
+
mapped,
|
|
1125
|
+
];
|
|
1126
|
+
for (const candidate of candidates) {
|
|
1127
|
+
if (Number.isFinite(candidate)) return Number(candidate);
|
|
1128
|
+
if (candidate && typeof candidate === 'object' && Number.isFinite(candidate.count)) {
|
|
1129
|
+
return Number(candidate.count);
|
|
1130
|
+
}
|
|
1131
|
+
}
|
|
1132
|
+
return null;
|
|
1133
|
+
}
|
|
1134
|
+
|
|
1135
|
+
function browsePriceCents(item) {
|
|
1136
|
+
const manifestPriceCents = item
|
|
1137
|
+
&& item.manifest
|
|
1138
|
+
&& typeof item.manifest === 'object'
|
|
1139
|
+
? item.manifest.priceCents
|
|
1140
|
+
: undefined;
|
|
1141
|
+
if (manifestPriceCents !== undefined && manifestPriceCents !== null) {
|
|
1142
|
+
return manifestPriceCents;
|
|
1143
|
+
}
|
|
1144
|
+
return item && typeof item === 'object' ? item.priceCents : undefined;
|
|
1145
|
+
}
|
|
1146
|
+
|
|
1147
|
+
function packBrowseRows(payload, { mine = false, limit = PACK_BROWSE_LIMIT } = {}) {
|
|
1148
|
+
return registryPackItems(payload).slice(0, limit).map((item) => {
|
|
1149
|
+
const manifest = packManifestFromItem(item);
|
|
1150
|
+
const priceCents = browsePriceCents(item);
|
|
1151
|
+
return {
|
|
1152
|
+
slug: cleanTableCell(manifest.slug, 'unknown', 40),
|
|
1153
|
+
title: cleanTableCell(manifest.title || manifest.name, manifest.slug || 'untitled', 42),
|
|
1154
|
+
version: cleanTableCell(manifest.version, 'unknown', 20),
|
|
1155
|
+
...(mine
|
|
1156
|
+
? { visibility: cleanTableCell(manifest.visibility, 'public', 10) }
|
|
1157
|
+
: {}),
|
|
1158
|
+
price: Number(priceCents) > 0 ? formatSalesDollars(priceCents) : 'free',
|
|
1159
|
+
stars: browseStarValue(item, payload, manifest.slug),
|
|
1160
|
+
};
|
|
1161
|
+
});
|
|
1162
|
+
}
|
|
1163
|
+
|
|
1164
|
+
function formatPackBrowseTable(payload, options = {}) {
|
|
1165
|
+
const mine = Boolean(options.mine);
|
|
1166
|
+
const rows = packBrowseRows(payload, {
|
|
1167
|
+
mine,
|
|
1168
|
+
limit: options.limit || PACK_BROWSE_LIMIT,
|
|
1169
|
+
});
|
|
1170
|
+
if (!rows.length) return '';
|
|
1171
|
+
|
|
1172
|
+
const showPrice = rows.some((row) => row.price !== 'free');
|
|
1173
|
+
const showStars = rows.some((row) => row.stars !== null);
|
|
1174
|
+
const columns = [
|
|
1175
|
+
{ key: 'slug', label: 'slug' },
|
|
1176
|
+
{ key: 'title', label: 'title' },
|
|
1177
|
+
{ key: 'version', label: 'version' },
|
|
1178
|
+
...(mine ? [{ key: 'visibility', label: 'visibility' }] : []),
|
|
1179
|
+
...(showPrice ? [{ key: 'price', label: 'price' }] : []),
|
|
1180
|
+
...(showStars ? [{ key: 'stars', label: 'stars' }] : []),
|
|
1181
|
+
];
|
|
1182
|
+
const widths = columns.map((column) => Math.max(
|
|
1183
|
+
column.label.length,
|
|
1184
|
+
...rows.map((row) => String(row[column.key] === null ? '-' : row[column.key]).length),
|
|
1185
|
+
));
|
|
1186
|
+
const render = (row) => columns
|
|
1187
|
+
.map((column, index) => {
|
|
1188
|
+
const value = row[column.key] === null ? '-' : String(row[column.key]);
|
|
1189
|
+
return index === columns.length - 1 ? value : value.padEnd(widths[index]);
|
|
1190
|
+
})
|
|
1191
|
+
.join(' ');
|
|
1192
|
+
return [
|
|
1193
|
+
render(Object.fromEntries(columns.map((column) => [column.key, column.label]))),
|
|
1194
|
+
...rows.map(render),
|
|
1195
|
+
].join('\n');
|
|
1196
|
+
}
|
|
1197
|
+
|
|
1198
|
+
function packSalesUrl(apiBaseUrl = getApiBaseUrl()) {
|
|
1199
|
+
return `${String(apiBaseUrl || '').replace(/\/+$/, '')}/pack/purchases/sales`;
|
|
1200
|
+
}
|
|
1201
|
+
|
|
1202
|
+
function packPurchasesUrl(apiBaseUrl = getApiBaseUrl()) {
|
|
1203
|
+
return `${String(apiBaseUrl || '').replace(/\/+$/, '')}/pack/purchases/mine`;
|
|
1204
|
+
}
|
|
1205
|
+
|
|
1206
|
+
function formatPackSaleDate(value) {
|
|
1207
|
+
const date = new Date(value);
|
|
1208
|
+
if (Number.isNaN(date.getTime())) return 'unknown';
|
|
1209
|
+
return new Intl.DateTimeFormat('en-US', {
|
|
1210
|
+
month: 'short',
|
|
1211
|
+
day: 'numeric',
|
|
1212
|
+
timeZone: 'UTC',
|
|
1213
|
+
}).format(date);
|
|
1214
|
+
}
|
|
1215
|
+
|
|
1216
|
+
function isPackTransactionRefunded(item) {
|
|
1217
|
+
return Boolean(item && item.refunded === true);
|
|
1218
|
+
}
|
|
1219
|
+
|
|
1220
|
+
function formatPackTransactionsTable(items, personKey, personLabel) {
|
|
1221
|
+
const rows = items.map((item) => ({
|
|
1222
|
+
pack: cleanTableCell(item && item.slug, 'unknown', 40),
|
|
1223
|
+
person: cleanTableCell(item && item[personKey], 'unknown', 40),
|
|
1224
|
+
price: formatSalesDollars(item && item.price_cents),
|
|
1225
|
+
date: formatPackSaleDate(item && item.granted_at),
|
|
1226
|
+
status: isPackTransactionRefunded(item) ? 'refunded' : '',
|
|
1227
|
+
}));
|
|
1228
|
+
if (!rows.length) return '';
|
|
1229
|
+
|
|
1230
|
+
const showStatus = rows.some((row) => row.status === 'refunded');
|
|
1231
|
+
const columns = [
|
|
1232
|
+
{ key: 'pack', label: 'pack' },
|
|
1233
|
+
{ key: 'person', label: personLabel },
|
|
1234
|
+
{ key: 'price', label: 'price' },
|
|
1235
|
+
{ key: 'date', label: 'date' },
|
|
1236
|
+
...(showStatus ? [{ key: 'status', label: 'status' }] : []),
|
|
1237
|
+
];
|
|
1238
|
+
const widths = columns.map((column) => Math.max(
|
|
1239
|
+
column.label.length,
|
|
1240
|
+
...rows.map((row) => row[column.key].length),
|
|
1241
|
+
));
|
|
1242
|
+
const render = (row) => columns
|
|
1243
|
+
.map((column, index) => (
|
|
1244
|
+
index === columns.length - 1
|
|
1245
|
+
? row[column.key]
|
|
1246
|
+
: row[column.key].padEnd(widths[index])
|
|
1247
|
+
))
|
|
1248
|
+
.join(' ');
|
|
1249
|
+
return [
|
|
1250
|
+
render(Object.fromEntries(columns.map((column) => [column.key, column.label]))),
|
|
1251
|
+
...rows.map(render),
|
|
1252
|
+
].join('\n');
|
|
1253
|
+
}
|
|
1254
|
+
|
|
1255
|
+
function formatPackSalesTable(sales) {
|
|
1256
|
+
return formatPackTransactionsTable(sales, 'buyer', 'buyer');
|
|
1257
|
+
}
|
|
1258
|
+
|
|
1259
|
+
function formatPackPurchasesTable(purchases) {
|
|
1260
|
+
return formatPackTransactionsTable(purchases, 'seller', 'seller');
|
|
1261
|
+
}
|
|
1262
|
+
|
|
1263
|
+
function formatBytes(value) {
|
|
1264
|
+
const bytes = Number(value) || 0;
|
|
1265
|
+
if (bytes < 1024) return `${bytes} B`;
|
|
1266
|
+
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
|
1267
|
+
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
|
1268
|
+
}
|
|
1269
|
+
|
|
1270
|
+
function printPacketComposition(entries) {
|
|
1271
|
+
const byTop = new Map();
|
|
1272
|
+
for (const entry of entries) {
|
|
1273
|
+
const parts = entry.name.split('/');
|
|
1274
|
+
const inner = parts[0] === 'atris' ? parts.slice(1) : parts;
|
|
1275
|
+
const top = inner.length > 1 ? `${inner[0]}/` : '(root)';
|
|
1276
|
+
byTop.set(top, (byTop.get(top) || 0) + 1);
|
|
1277
|
+
}
|
|
1278
|
+
console.log('composition:');
|
|
1279
|
+
for (const [top, count] of [...byTop.entries()].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))) {
|
|
1280
|
+
console.log(` ${top.padEnd(16)} ${count}`);
|
|
1281
|
+
}
|
|
1282
|
+
}
|
|
1283
|
+
|
|
1284
|
+
function printPacketTree(entries) {
|
|
1285
|
+
console.log('tree:');
|
|
1286
|
+
if (entries.length <= 200) {
|
|
1287
|
+
for (const entry of entries) console.log(` ${entry.name}`);
|
|
1288
|
+
return;
|
|
1289
|
+
}
|
|
1290
|
+
const byDir = new Map();
|
|
1291
|
+
for (const entry of entries) {
|
|
1292
|
+
const dir = entry.name.includes('/') ? `${entry.name.slice(0, entry.name.lastIndexOf('/'))}/` : './';
|
|
1293
|
+
const current = byDir.get(dir) || { files: 0, bytes: 0 };
|
|
1294
|
+
current.files += 1;
|
|
1295
|
+
current.bytes += entry.data.length;
|
|
1296
|
+
byDir.set(dir, current);
|
|
1297
|
+
}
|
|
1298
|
+
console.log(` (${entries.length} files across ${byDir.size} folders, rolled up by folder)`);
|
|
1299
|
+
for (const [dir, stats] of [...byDir.entries()].sort()) {
|
|
1300
|
+
console.log(` ${dir} ${stats.files} files ${formatBytes(stats.bytes)}`);
|
|
1301
|
+
}
|
|
1302
|
+
}
|
|
1303
|
+
|
|
1304
|
+
function printSkipped(skipped) {
|
|
1305
|
+
if (!skipped.length) return;
|
|
1306
|
+
const byReason = new Map();
|
|
1307
|
+
for (const item of skipped) {
|
|
1308
|
+
const list = byReason.get(item.reason) || [];
|
|
1309
|
+
list.push(item.path);
|
|
1310
|
+
byReason.set(item.reason, list);
|
|
1311
|
+
}
|
|
1312
|
+
console.log('skipped:');
|
|
1313
|
+
for (const [reason, paths] of [...byReason.entries()].sort((a, b) => b[1].length - a[1].length)) {
|
|
1314
|
+
console.log(` ${reason}: ${paths.length}`);
|
|
1315
|
+
for (const item of paths.slice(0, 5)) console.log(` ${item}`);
|
|
1316
|
+
if (paths.length > 5) console.log(` ... ${paths.length - 5} more`);
|
|
1317
|
+
}
|
|
1318
|
+
}
|
|
1319
|
+
|
|
1320
|
+
function printPacketSummary(manifest, entries, skipped, zipBytes) {
|
|
1321
|
+
const unpacked = entries.reduce((total, entry) => total + entry.data.length, 0);
|
|
1322
|
+
console.log(`pack ${manifest.slug} ${manifest.version}`);
|
|
1323
|
+
console.log(` files ${entries.length} (limit ${REGISTRY_LIMITS.maxEntries})`);
|
|
1324
|
+
console.log(` unpacked ${formatBytes(unpacked)} (limit ${formatBytes(REGISTRY_LIMITS.maxUnpackedBytes)})`);
|
|
1325
|
+
console.log(` zip ${formatBytes(zipBytes)} (limit ${formatBytes(REGISTRY_LIMITS.maxZipBytes)})`);
|
|
1326
|
+
printPacketComposition(entries);
|
|
1327
|
+
printPacketTree(entries);
|
|
1328
|
+
printSkipped(skipped);
|
|
1329
|
+
return { unpacked, zipBytes, files: entries.length };
|
|
1330
|
+
}
|
|
1331
|
+
|
|
1332
|
+
function registryLimitFailures(entries, zipBytes) {
|
|
1333
|
+
const unpacked = entries.reduce((total, entry) => total + entry.data.length, 0);
|
|
1334
|
+
const failures = [];
|
|
1335
|
+
if (entries.length > REGISTRY_LIMITS.maxEntries) {
|
|
1336
|
+
failures.push(`entry count: ${entries.length} files exceeds the ${REGISTRY_LIMITS.maxEntries} file limit`);
|
|
1337
|
+
}
|
|
1338
|
+
if (unpacked > REGISTRY_LIMITS.maxUnpackedBytes) {
|
|
1339
|
+
failures.push(`unpacked size: ${formatBytes(unpacked)} exceeds the ${formatBytes(REGISTRY_LIMITS.maxUnpackedBytes)} unpacked limit`);
|
|
1340
|
+
}
|
|
1341
|
+
if (zipBytes > REGISTRY_LIMITS.maxZipBytes) {
|
|
1342
|
+
failures.push(`zip size: ${formatBytes(zipBytes)} exceeds the ${formatBytes(REGISTRY_LIMITS.maxZipBytes)} zip limit`);
|
|
1343
|
+
}
|
|
1344
|
+
return failures;
|
|
1345
|
+
}
|
|
1346
|
+
|
|
1347
|
+
function reportSecretFindings(findings) {
|
|
1348
|
+
console.error(`refusing to publish: found ${findings.length} credential-shaped match${findings.length === 1 ? '' : 'es'}.`);
|
|
1349
|
+
for (const finding of findings.slice(0, 20)) {
|
|
1350
|
+
console.error(` ${finding.file}:${finding.line} ${finding.label} ${finding.redacted}`);
|
|
1351
|
+
}
|
|
1352
|
+
if (findings.length > 20) console.error(` ... ${findings.length - 20} more`);
|
|
1353
|
+
console.error('remove the credentials, or re-run with --allow-secrets if every match is a false positive.');
|
|
1354
|
+
}
|
|
1355
|
+
|
|
1356
|
+
function shellQuote(value) {
|
|
1357
|
+
const text = String(value || '.');
|
|
1358
|
+
if (/^[A-Za-z0-9_./:-]+$/.test(text)) return text;
|
|
1359
|
+
return `'${text.replace(/'/g, "'\\''")}'`;
|
|
1360
|
+
}
|
|
1361
|
+
|
|
1362
|
+
function fallbackSlugFromZipPath(zipPath) {
|
|
1363
|
+
return slugify(path.basename(zipPath, path.extname(zipPath)), 'atris-pack');
|
|
1364
|
+
}
|
|
1365
|
+
|
|
1366
|
+
async function loadZipPayload(source, cwd, deps = {}) {
|
|
1367
|
+
const request = deps.httpRequest || httpRequest;
|
|
1368
|
+
if (/^https?:\/\//i.test(source)) {
|
|
1369
|
+
const response = await request(source, { method: 'GET' });
|
|
1370
|
+
if (response.status < 200 || response.status >= 300) {
|
|
1371
|
+
throw new Error(`download failed with status ${response.status}`);
|
|
1372
|
+
}
|
|
1373
|
+
const urlPath = new URL(source).pathname;
|
|
1374
|
+
return {
|
|
1375
|
+
buffer: response.body,
|
|
1376
|
+
fallbackSlug: fallbackSlugFromZipPath(urlPath || 'atris-pack.zip'),
|
|
1377
|
+
sourceType: 'url',
|
|
1378
|
+
sourceUrl: source,
|
|
1379
|
+
};
|
|
1380
|
+
}
|
|
1381
|
+
|
|
1382
|
+
const localPath = path.resolve(cwd, source);
|
|
1383
|
+
if (fs.existsSync(localPath) || source.toLowerCase().endsWith('.zip')) {
|
|
1384
|
+
if (!fs.existsSync(localPath)) throw new Error(`zip file not found: ${source}`);
|
|
1385
|
+
return {
|
|
1386
|
+
buffer: fs.readFileSync(localPath),
|
|
1387
|
+
fallbackSlug: fallbackSlugFromZipPath(localPath),
|
|
1388
|
+
sourceType: 'file',
|
|
1389
|
+
sourcePath: localPath,
|
|
1390
|
+
};
|
|
1391
|
+
}
|
|
1392
|
+
|
|
1393
|
+
const slug = slugify(source);
|
|
1394
|
+
const buffer = await fetchRegistryZipForUser(slug, deps);
|
|
1395
|
+
if (!buffer) return null;
|
|
1396
|
+
return { buffer, fallbackSlug: slug, sourceType: 'registry', sourceSlug: slug };
|
|
1397
|
+
}
|
|
1398
|
+
|
|
1399
|
+
function parseManifest(entries) {
|
|
1400
|
+
const manifestEntries = entries.filter((entry) => (
|
|
1401
|
+
canonicalZipEntryName(entry.name) === 'pack.json'
|
|
1402
|
+
&& !String(entry.name || '').replace(/\\/g, '/').endsWith('/')
|
|
1403
|
+
));
|
|
1404
|
+
if (manifestEntries.length === 0) {
|
|
1405
|
+
throw new Error('pack archive is missing root pack.json');
|
|
1406
|
+
}
|
|
1407
|
+
if (manifestEntries.length > 1) {
|
|
1408
|
+
throw new Error('pack archive contains duplicate pack.json entries');
|
|
1409
|
+
}
|
|
1410
|
+
|
|
1411
|
+
const manifestEntry = manifestEntries[0];
|
|
1412
|
+
let parsed;
|
|
1413
|
+
try {
|
|
1414
|
+
parsed = JSON.parse(manifestEntry.data.toString('utf8'));
|
|
1415
|
+
} catch (error) {
|
|
1416
|
+
throw new Error(`pack archive has invalid pack.json: ${error.message}`);
|
|
1417
|
+
}
|
|
1418
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
1419
|
+
throw new Error('pack archive pack.json must contain an object');
|
|
1420
|
+
}
|
|
1421
|
+
if (typeof parsed.slug !== 'string' || !parsed.slug.trim()) {
|
|
1422
|
+
throw new Error('pack archive pack.json is missing slug');
|
|
1423
|
+
}
|
|
1424
|
+
return parsed;
|
|
1425
|
+
}
|
|
1426
|
+
|
|
1427
|
+
function manifestVersion(manifest) {
|
|
1428
|
+
return manifest.version
|
|
1429
|
+
|| (Array.isArray(manifest.versions) && manifest.versions[0] && manifest.versions[0].version)
|
|
1430
|
+
|| 'unknown';
|
|
1431
|
+
}
|
|
1432
|
+
|
|
1433
|
+
function upstreamStatePath(packDir) {
|
|
1434
|
+
return path.join(packDir, '.upstream', 'STATE.json');
|
|
1435
|
+
}
|
|
1436
|
+
|
|
1437
|
+
function packStatePath(packDir) {
|
|
1438
|
+
return path.join(packDir, '.atris', 'state', 'pack.json');
|
|
1439
|
+
}
|
|
1440
|
+
|
|
1441
|
+
function remoteCheckTime(deps = {}) {
|
|
1442
|
+
const value = typeof deps.now === 'function' ? deps.now() : new Date();
|
|
1443
|
+
const date = value instanceof Date ? value : new Date(value);
|
|
1444
|
+
return date.toISOString();
|
|
1445
|
+
}
|
|
1446
|
+
|
|
1447
|
+
function recordRemoteCheck(packDir, details, deps = {}) {
|
|
1448
|
+
const statePath = packStatePath(packDir);
|
|
1449
|
+
const existing = readJson(statePath);
|
|
1450
|
+
const state = {
|
|
1451
|
+
...(existing && typeof existing === 'object' ? existing : {}),
|
|
1452
|
+
slug: details.slug,
|
|
1453
|
+
origin: details.origin,
|
|
1454
|
+
remoteVersion: details.remoteVersion,
|
|
1455
|
+
lastRemoteCheckAt: remoteCheckTime(deps),
|
|
1456
|
+
};
|
|
1457
|
+
writeJson(statePath, state);
|
|
1458
|
+
return state;
|
|
1459
|
+
}
|
|
1460
|
+
|
|
1461
|
+
function buildUpstreamState(slug, localVersion, remoteVersion) {
|
|
1462
|
+
return {
|
|
1463
|
+
slug,
|
|
1464
|
+
localVersion,
|
|
1465
|
+
remoteVersion,
|
|
1466
|
+
pulledAt: new Date().toISOString(),
|
|
1467
|
+
};
|
|
1468
|
+
}
|
|
1469
|
+
|
|
1470
|
+
function writeUpstreamState(packDir, state) {
|
|
1471
|
+
writeJson(upstreamStatePath(packDir), state);
|
|
1472
|
+
}
|
|
1473
|
+
|
|
1474
|
+
function hasStagedUpstream(packDir) {
|
|
1475
|
+
const upstreamDir = path.join(packDir, '.upstream');
|
|
1476
|
+
if (!fs.existsSync(upstreamDir)) return false;
|
|
1477
|
+
return fs.readdirSync(upstreamDir).some((name) => name !== 'STATE.json');
|
|
1478
|
+
}
|
|
1479
|
+
|
|
1480
|
+
module.exports = {
|
|
1481
|
+
REGISTRY_TIMEOUT_MS,
|
|
1482
|
+
PACK_RUN_INPUT_MAX_BYTES,
|
|
1483
|
+
PACK_VISIBILITIES,
|
|
1484
|
+
DEFAULT_SHARE_DAYS,
|
|
1485
|
+
MAX_SHARE_DAYS,
|
|
1486
|
+
MAX_SHARE_RECIPIENT_LENGTH,
|
|
1487
|
+
SHARE_NONCE_RULE,
|
|
1488
|
+
PACK_BROWSE_LIMIT,
|
|
1489
|
+
REGISTRY_LIMITS,
|
|
1490
|
+
SLUG_RULE,
|
|
1491
|
+
PACKET,
|
|
1492
|
+
SECRET_PATTERNS,
|
|
1493
|
+
slugify,
|
|
1494
|
+
titleFromSlug,
|
|
1495
|
+
takeValue,
|
|
1496
|
+
takeValues,
|
|
1497
|
+
takeFlag,
|
|
1498
|
+
parseSemver,
|
|
1499
|
+
bumpVersion,
|
|
1500
|
+
comparePackVersions,
|
|
1501
|
+
readJson,
|
|
1502
|
+
writeJson,
|
|
1503
|
+
buildManifest,
|
|
1504
|
+
packetDirectories,
|
|
1505
|
+
packetDeniedSegments,
|
|
1506
|
+
isRunningStateName,
|
|
1507
|
+
classifyPacketPath,
|
|
1508
|
+
isUtf8Text,
|
|
1509
|
+
collectPacketEntries,
|
|
1510
|
+
sha256,
|
|
1511
|
+
canonicalZipEntryName,
|
|
1512
|
+
canonicalContentPath,
|
|
1513
|
+
contentEntries,
|
|
1514
|
+
buildContentHashes,
|
|
1515
|
+
parseContentHashes,
|
|
1516
|
+
verifyArchiveContentHashes,
|
|
1517
|
+
contentHashFingerprint,
|
|
1518
|
+
redactSecret,
|
|
1519
|
+
trimSecretValue,
|
|
1520
|
+
looksLikePlaceholder,
|
|
1521
|
+
looksLikeCredentialValue,
|
|
1522
|
+
scanTextForSecrets,
|
|
1523
|
+
scanEntriesForSecrets,
|
|
1524
|
+
parseJsonBody,
|
|
1525
|
+
responseErrorText,
|
|
1526
|
+
registryUrl,
|
|
1527
|
+
registryOrigin,
|
|
1528
|
+
optionalAuthHeaders,
|
|
1529
|
+
refreshApiWithoutProviderHint,
|
|
1530
|
+
refreshRegistryCredentials,
|
|
1531
|
+
requestRegistryZip,
|
|
1532
|
+
registryNotFoundError,
|
|
1533
|
+
requiredAuthHeaders,
|
|
1534
|
+
assertPublishableSlug,
|
|
1535
|
+
assertPublishableAuthor,
|
|
1536
|
+
postPackToRegistry,
|
|
1537
|
+
paidPackBarrierFromResponse,
|
|
1538
|
+
formatSalesDollars,
|
|
1539
|
+
printPaidPackBarrier,
|
|
1540
|
+
throwPaidPackBarrier,
|
|
1541
|
+
fetchRegistryZip,
|
|
1542
|
+
fetchRegistryZipForUser,
|
|
1543
|
+
sanitizePersonalizationName,
|
|
1544
|
+
parsePackShareArgs,
|
|
1545
|
+
requestRegistryJson,
|
|
1546
|
+
mintPackShare,
|
|
1547
|
+
listPackShares,
|
|
1548
|
+
revokePackShares,
|
|
1549
|
+
revokePackShare,
|
|
1550
|
+
registryPackItems,
|
|
1551
|
+
packManifestFromItem,
|
|
1552
|
+
isPublicPackPayload,
|
|
1553
|
+
registryConfirmsPublicPack,
|
|
1554
|
+
legacyPersonalizedShareUrl,
|
|
1555
|
+
formatShareExpiry,
|
|
1556
|
+
cleanTableCell,
|
|
1557
|
+
formatPackShareLinksTable,
|
|
1558
|
+
browseStarValue,
|
|
1559
|
+
browsePriceCents,
|
|
1560
|
+
packBrowseRows,
|
|
1561
|
+
formatPackBrowseTable,
|
|
1562
|
+
packSalesUrl,
|
|
1563
|
+
packPurchasesUrl,
|
|
1564
|
+
formatPackSaleDate,
|
|
1565
|
+
isPackTransactionRefunded,
|
|
1566
|
+
formatPackTransactionsTable,
|
|
1567
|
+
formatPackSalesTable,
|
|
1568
|
+
formatPackPurchasesTable,
|
|
1569
|
+
formatBytes,
|
|
1570
|
+
printPacketComposition,
|
|
1571
|
+
printPacketTree,
|
|
1572
|
+
printSkipped,
|
|
1573
|
+
printPacketSummary,
|
|
1574
|
+
registryLimitFailures,
|
|
1575
|
+
reportSecretFindings,
|
|
1576
|
+
shellQuote,
|
|
1577
|
+
fallbackSlugFromZipPath,
|
|
1578
|
+
loadZipPayload,
|
|
1579
|
+
parseManifest,
|
|
1580
|
+
manifestVersion,
|
|
1581
|
+
upstreamStatePath,
|
|
1582
|
+
packStatePath,
|
|
1583
|
+
remoteCheckTime,
|
|
1584
|
+
recordRemoteCheck,
|
|
1585
|
+
buildUpstreamState,
|
|
1586
|
+
writeUpstreamState,
|
|
1587
|
+
hasStagedUpstream,
|
|
1588
|
+
};
|