@arcote.tech/arc-cli 0.7.15 → 0.7.17
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/dist/index.js +642 -776
- package/package.json +9 -9
- package/src/builder/chunk-planner.ts +92 -8
- package/src/platform/server.ts +14 -11
- package/src/platform/shared.ts +5 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@arcote.tech/arc-cli",
|
|
3
|
-
"version": "0.7.
|
|
3
|
+
"version": "0.7.17",
|
|
4
4
|
"description": "CLI tool for Arc framework",
|
|
5
5
|
"module": "index.ts",
|
|
6
6
|
"main": "dist/index.js",
|
|
@@ -12,13 +12,13 @@
|
|
|
12
12
|
"build": "bun build --target=bun ./src/index.ts --outdir=dist --external @arcote.tech/arc --external @arcote.tech/arc-ds --external @arcote.tech/arc-react --external @arcote.tech/platform --external '@opentelemetry/*' && chmod +x dist/index.js"
|
|
13
13
|
},
|
|
14
14
|
"dependencies": {
|
|
15
|
-
"@arcote.tech/arc": "^0.7.
|
|
16
|
-
"@arcote.tech/arc-ds": "^0.7.
|
|
17
|
-
"@arcote.tech/arc-react": "^0.7.
|
|
18
|
-
"@arcote.tech/arc-host": "^0.7.
|
|
19
|
-
"@arcote.tech/arc-adapter-db-sqlite": "^0.7.
|
|
20
|
-
"@arcote.tech/arc-adapter-db-postgres": "^0.7.
|
|
21
|
-
"@arcote.tech/arc-otel": "^0.7.
|
|
15
|
+
"@arcote.tech/arc": "^0.7.17",
|
|
16
|
+
"@arcote.tech/arc-ds": "^0.7.17",
|
|
17
|
+
"@arcote.tech/arc-react": "^0.7.17",
|
|
18
|
+
"@arcote.tech/arc-host": "^0.7.17",
|
|
19
|
+
"@arcote.tech/arc-adapter-db-sqlite": "^0.7.17",
|
|
20
|
+
"@arcote.tech/arc-adapter-db-postgres": "^0.7.17",
|
|
21
|
+
"@arcote.tech/arc-otel": "^0.7.17",
|
|
22
22
|
"@opentelemetry/api": "^1.9.0",
|
|
23
23
|
"@opentelemetry/api-logs": "^0.57.0",
|
|
24
24
|
"@opentelemetry/core": "^1.30.0",
|
|
@@ -31,7 +31,7 @@
|
|
|
31
31
|
"@opentelemetry/sdk-trace-base": "^1.30.0",
|
|
32
32
|
"@opentelemetry/sdk-trace-node": "^1.30.0",
|
|
33
33
|
"@opentelemetry/semantic-conventions": "^1.27.0",
|
|
34
|
-
"@arcote.tech/platform": "^0.7.
|
|
34
|
+
"@arcote.tech/platform": "^0.7.17",
|
|
35
35
|
"@clack/prompts": "^0.9.0",
|
|
36
36
|
"commander": "^11.1.0",
|
|
37
37
|
"chokidar": "^3.5.3",
|
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { readFileSync, readdirSync, statSync } from "fs";
|
|
2
|
+
import { basename, join } from "path";
|
|
2
3
|
import type { SerializedAccessMap } from "./access-extractor";
|
|
3
4
|
import type { WorkspacePackage } from "./module-builder";
|
|
4
5
|
|
|
@@ -79,6 +80,78 @@ export function planChunks(
|
|
|
79
80
|
return { assignments, groups, chunks };
|
|
80
81
|
}
|
|
81
82
|
|
|
83
|
+
// ---------------------------------------------------------------------------
|
|
84
|
+
// Single-module-per-package invariant
|
|
85
|
+
//
|
|
86
|
+
// Chunk grouping is per workspace PACKAGE (planChunks reads the access of the
|
|
87
|
+
// one module derived from the package name and assigns the whole package to
|
|
88
|
+
// that token's chunk). A second `module()` in the same package is invisible to
|
|
89
|
+
// the planner — it silently rides the first module's chunk and token gating.
|
|
90
|
+
// That's how a public/userToken onboarding page can end up trapped in a
|
|
91
|
+
// workspaceToken chunk. Fail the build loudly instead.
|
|
92
|
+
// ---------------------------------------------------------------------------
|
|
93
|
+
|
|
94
|
+
const MODULE_CALL = /\bmodule\(\s*["'`]([a-zA-Z0-9_-]+)["'`]/g;
|
|
95
|
+
|
|
96
|
+
function collectModuleNames(dir: string, acc: Set<string>): void {
|
|
97
|
+
let entries: string[];
|
|
98
|
+
try {
|
|
99
|
+
entries = readdirSync(dir);
|
|
100
|
+
} catch {
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
for (const e of entries) {
|
|
104
|
+
if (e === "node_modules" || e === "dist") continue;
|
|
105
|
+
const full = join(dir, e);
|
|
106
|
+
let isDir = false;
|
|
107
|
+
try {
|
|
108
|
+
isDir = statSync(full).isDirectory();
|
|
109
|
+
} catch {
|
|
110
|
+
continue;
|
|
111
|
+
}
|
|
112
|
+
if (isDir) {
|
|
113
|
+
collectModuleNames(full, acc);
|
|
114
|
+
} else if (e.endsWith(".ts") || e.endsWith(".tsx")) {
|
|
115
|
+
const src = readFileSync(full, "utf-8");
|
|
116
|
+
MODULE_CALL.lastIndex = 0;
|
|
117
|
+
let m: RegExpExecArray | null;
|
|
118
|
+
while ((m = MODULE_CALL.exec(src))) acc.add(m[1]);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Assert every workspace package declares at most one `module()`. Throws a
|
|
125
|
+
* build error listing offenders, with the fix (split each extra module into
|
|
126
|
+
* its own package). Call before chunk planning.
|
|
127
|
+
*/
|
|
128
|
+
export function assertOneModulePerPackage(
|
|
129
|
+
packages: readonly WorkspacePackage[],
|
|
130
|
+
): void {
|
|
131
|
+
const offenders: { pkg: string; modules: string[] }[] = [];
|
|
132
|
+
for (const pkg of packages) {
|
|
133
|
+
const names = new Set<string>();
|
|
134
|
+
collectModuleNames(join(pkg.path, "src"), names);
|
|
135
|
+
if (names.size > 1) {
|
|
136
|
+
offenders.push({ pkg: pkg.name, modules: [...names].sort() });
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
if (offenders.length === 0) return;
|
|
140
|
+
|
|
141
|
+
const detail = offenders
|
|
142
|
+
.map((o) => ` • ${o.pkg} declares ${o.modules.length}: ${o.modules.join(", ")}`)
|
|
143
|
+
.join("\n");
|
|
144
|
+
throw new Error(
|
|
145
|
+
`Arc build: a workspace package must declare at most ONE module().\n` +
|
|
146
|
+
`Chunk grouping is per-package — a second module() silently inherits the ` +
|
|
147
|
+
`first module's token protection and chunk, so e.g. a public onboarding ` +
|
|
148
|
+
`page sharing a package with a workspaceToken-gated module becomes ` +
|
|
149
|
+
`unreachable for users without that token.\n${detail}\n` +
|
|
150
|
+
`Fix: move each extra module() into its own workspace package ` +
|
|
151
|
+
`(declare its own protectedBy there).`,
|
|
152
|
+
);
|
|
153
|
+
}
|
|
154
|
+
|
|
82
155
|
// ---------------------------------------------------------------------------
|
|
83
156
|
// Helpers
|
|
84
157
|
// ---------------------------------------------------------------------------
|
|
@@ -95,13 +168,24 @@ function resolveChunk(
|
|
|
95
168
|
if (!access || access.rules.length === 0) return PUBLIC_CHUNK;
|
|
96
169
|
|
|
97
170
|
const tokenNames = new Set(access.rules.map((r) => r.token.name));
|
|
98
|
-
if (tokenNames.size
|
|
99
|
-
|
|
171
|
+
if (tokenNames.size > 1) {
|
|
172
|
+
const list = [...tokenNames].sort().join(", ");
|
|
173
|
+
throw new Error(
|
|
174
|
+
`Module "${moduleName}" has access rules for multiple tokens [${list}]. ` +
|
|
175
|
+
`Multi-token modules are not supported — split the module or unify on a single token type.`,
|
|
176
|
+
);
|
|
100
177
|
}
|
|
101
178
|
|
|
102
|
-
const
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
179
|
+
const tokenName = [...tokenNames][0];
|
|
180
|
+
const hasCheck = access.rules.some((r) => r.hasCheck);
|
|
181
|
+
|
|
182
|
+
// A module with a check (e.g. `protectedBy(userToken, t => t.role==='admin')`)
|
|
183
|
+
// has NARROWER access than the bare token. The server grants a chunk
|
|
184
|
+
// all-or-nothing: it's served only if EVERY module in it passes its check.
|
|
185
|
+
// So a checked module sharing a chunk with token-only modules would block
|
|
186
|
+
// them for users who hold the token but fail the check (an admin gate
|
|
187
|
+
// trapping an onboarding page). Give each checked module its own chunk;
|
|
188
|
+
// token-only modules group together under the token name. Chunk name must
|
|
189
|
+
// be filesystem/URL-safe (BROWSER_FILE_RE) — `__` separator, not `:`.
|
|
190
|
+
return hasCheck ? `${tokenName}__${moduleName}` : tokenName;
|
|
107
191
|
}
|
package/src/platform/server.ts
CHANGED
|
@@ -266,9 +266,11 @@ function parseArcTokensHeader(header: string | null): any[] {
|
|
|
266
266
|
|
|
267
267
|
/**
|
|
268
268
|
* Filter manifest groups to those the caller's tokens grant access to. A
|
|
269
|
-
* group is unlocked when
|
|
270
|
-
*
|
|
271
|
-
*
|
|
269
|
+
* group is unlocked when EVERY module in it is granted: the caller holds a
|
|
270
|
+
* token whose name matches one of the module's `protectedBy(token, check)`
|
|
271
|
+
* rules, and the rule's check (if any) passes. Group names are not 1:1 with
|
|
272
|
+
* token names — a checked module gets its own chunk (`<token>__<module>`),
|
|
273
|
+
* so grants are resolved per-module via the access map, not by group name.
|
|
272
274
|
*
|
|
273
275
|
* Returns a manifest with `groups` filtered + each remaining entry's `url`
|
|
274
276
|
* filled with a signed URL (HMAC, 1h TTL). `initial` + `sharedChunks` ride
|
|
@@ -279,26 +281,27 @@ async function filterManifestForTokens(
|
|
|
279
281
|
moduleAccessMap: Map<string, ModuleAccess>,
|
|
280
282
|
tokenPayloads: any[],
|
|
281
283
|
): Promise<BuildManifest> {
|
|
282
|
-
|
|
284
|
+
// Chunk group names are no longer 1:1 with token names (a checked module
|
|
285
|
+
// gets its own chunk `<token>__<module>`), so grant by the per-module
|
|
286
|
+
// access rules, not by the group name. Look tokens up by their name.
|
|
287
|
+
const tokensByName = new Map<string, any>();
|
|
283
288
|
for (const t of tokenPayloads) {
|
|
284
|
-
if (t?.tokenType)
|
|
289
|
+
if (t?.tokenType) tokensByName.set(t.tokenType, t);
|
|
285
290
|
}
|
|
286
291
|
|
|
287
292
|
const filteredGroups: Record<string, BuildManifestGroup> = {};
|
|
288
293
|
|
|
289
294
|
for (const [name, group] of Object.entries(manifest.groups)) {
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
//
|
|
293
|
-
// If all rules pass (or none declared), unlock the group bundle.
|
|
295
|
+
// Every module in the group must be granted by a held token (and pass
|
|
296
|
+
// its check, if any). Chunks are homogeneous post-split — a checked
|
|
297
|
+
// module is alone in its chunk, so its check can't block other modules.
|
|
294
298
|
let allGranted = true;
|
|
295
299
|
for (const moduleName of group.modules) {
|
|
296
300
|
const access = moduleAccessMap.get(moduleName);
|
|
297
301
|
if (!access || access.rules.length === 0) continue;
|
|
298
302
|
let granted = false;
|
|
299
303
|
for (const rule of access.rules) {
|
|
300
|
-
|
|
301
|
-
const matching = tokenPayloads.find((t) => t.tokenType === name);
|
|
304
|
+
const matching = tokensByName.get(rule.token.name);
|
|
302
305
|
if (!matching) continue;
|
|
303
306
|
granted = rule.check ? await rule.check(matching) : true;
|
|
304
307
|
if (granted) break;
|
package/src/platform/shared.ts
CHANGED
|
@@ -21,7 +21,7 @@ import {
|
|
|
21
21
|
type BuildCache,
|
|
22
22
|
} from "../builder/build-cache";
|
|
23
23
|
import { extractAccessMap } from "../builder/access-extractor";
|
|
24
|
-
import { planChunks } from "../builder/chunk-planner";
|
|
24
|
+
import { assertOneModulePerPackage, planChunks } from "../builder/chunk-planner";
|
|
25
25
|
import { collectFrameworkDeps } from "../builder/dependency-collector";
|
|
26
26
|
import {
|
|
27
27
|
mtimeOf,
|
|
@@ -157,6 +157,10 @@ export async function buildAll(
|
|
|
157
157
|
|
|
158
158
|
log(`Building (concurrency parallel${noCache ? ", no-cache" : ""})...`);
|
|
159
159
|
|
|
160
|
+
// Phase 0 — invariant check before any work: one module() per package,
|
|
161
|
+
// else chunk grouping (per-package) silently mis-assigns the extra module.
|
|
162
|
+
assertOneModulePerPackage(ws.packages);
|
|
163
|
+
|
|
160
164
|
// Phase 1 — context packages must finish FIRST. The access-extractor
|
|
161
165
|
// subprocess imports workspace packages by name, which resolve through
|
|
162
166
|
// node_modules to packages' `main` field (typically `dist/server/main/`).
|