@arcote.tech/arc-cli 0.4.6 → 0.4.7

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@arcote.tech/arc-cli",
3
- "version": "0.4.6",
3
+ "version": "0.4.7",
4
4
  "description": "CLI tool for Arc framework",
5
5
  "module": "index.ts",
6
6
  "main": "dist/index.js",
@@ -97,8 +97,13 @@ export interface WorkspacePackage {
97
97
  packageJson: Record<string, any>;
98
98
  }
99
99
 
100
+ export interface ModuleEntry {
101
+ file: string;
102
+ name: string;
103
+ }
104
+
100
105
  export interface BuildManifest {
101
- modules: string[];
106
+ modules: ModuleEntry[];
102
107
  buildTime: string;
103
108
  }
104
109
 
@@ -203,9 +208,13 @@ export async function buildPackages(
203
208
  mkdirSync(tmpDir, { recursive: true });
204
209
 
205
210
  const entrypoints: string[] = [];
211
+ const fileToName = new Map<string, string>(); // safeName → module name
206
212
 
207
213
  for (const pkg of packages) {
208
214
  const safeName = pkg.path.split("/").pop()!;
215
+ // Module name: strip scope (e.g. @ndt/admin → admin)
216
+ const moduleName = pkg.name.includes("/") ? pkg.name.split("/").pop()! : pkg.name;
217
+ fileToName.set(safeName, moduleName);
209
218
 
210
219
  // All packages get a simple re-export wrapper.
211
220
  // Context packages that use module().build() self-register via side effects.
@@ -262,12 +271,16 @@ export async function buildPackages(
262
271
  rmSync(tmpDir, { recursive: true, force: true });
263
272
 
264
273
  // Build manifest
265
- const moduleFiles = result.outputs
274
+ const moduleEntries: ModuleEntry[] = result.outputs
266
275
  .filter((o) => o.kind === "entry-point")
267
- .map((o) => o.path.split("/").pop()!);
276
+ .map((o) => {
277
+ const file = o.path.split("/").pop()!;
278
+ const safeName = file.replace(/\.js$/, "");
279
+ return { file, name: fileToName.get(safeName) ?? safeName };
280
+ });
268
281
 
269
282
  const manifest: BuildManifest = {
270
- modules: moduleFiles,
283
+ modules: moduleEntries,
271
284
  buildTime: new Date().toISOString(),
272
285
  };
273
286
 
@@ -21,7 +21,7 @@ export async function platformDev(): Promise<void> {
21
21
 
22
22
  // Load server context
23
23
  log("Loading server context...");
24
- const context = await loadServerContext(ws.packages);
24
+ const { context, moduleAccess } = await loadServerContext(ws.packages);
25
25
  if (context) {
26
26
  ok("Context loaded");
27
27
  } else {
@@ -34,6 +34,7 @@ export async function platformDev(): Promise<void> {
34
34
  port,
35
35
  manifest,
36
36
  context,
37
+ moduleAccess,
37
38
  dbPath: join(ws.rootDir, ".arc", "data", "dev.db"),
38
39
  devMode: true,
39
40
  });
@@ -26,7 +26,7 @@ export async function platformStart(): Promise<void> {
26
26
 
27
27
  // Load server context
28
28
  log("Loading server context...");
29
- const context = await loadServerContext(ws.packages);
29
+ const { context, moduleAccess } = await loadServerContext(ws.packages);
30
30
  if (context) {
31
31
  ok("Context loaded");
32
32
  } else {
@@ -39,6 +39,7 @@ export async function platformStart(): Promise<void> {
39
39
  port,
40
40
  manifest,
41
41
  context,
42
+ moduleAccess,
42
43
  dbPath: join(ws.rootDir, ".arc", "data", "prod.db"),
43
44
  devMode: false,
44
45
  });
@@ -18,6 +18,7 @@ export interface CatalogEntry {
18
18
  locations: string[];
19
19
  hash: string;
20
20
  obsolete: boolean;
21
+ manual?: boolean;
21
22
  }
22
23
 
23
24
  /** Short hash from msgid for change tracking */
@@ -37,6 +38,7 @@ export function parsePo(content: string): CatalogEntry[] {
37
38
  let msgid = "";
38
39
  let msgstr = "";
39
40
  let obsolete = false;
41
+ let inManual = false;
40
42
 
41
43
  const flush = () => {
42
44
  if (msgid) {
@@ -46,6 +48,7 @@ export function parsePo(content: string): CatalogEntry[] {
46
48
  locations,
47
49
  hash: hash || hashMsgid(msgid),
48
50
  obsolete,
51
+ ...(inManual ? { manual: true } : {}),
49
52
  });
50
53
  }
51
54
  locations = [];
@@ -58,6 +61,17 @@ export function parsePo(content: string): CatalogEntry[] {
58
61
  for (const line of lines) {
59
62
  const trimmed = line.trim();
60
63
 
64
+ // Manual section markers
65
+ if (trimmed === "# manual") {
66
+ inManual = true;
67
+ continue;
68
+ }
69
+ if (trimmed === "# end manual") {
70
+ if (msgid) flush();
71
+ inManual = false;
72
+ continue;
73
+ }
74
+
61
75
  if (trimmed === "" || trimmed.startsWith("#,")) {
62
76
  // Empty line or flags — boundary between entries
63
77
  if (msgid) flush();
@@ -110,10 +124,23 @@ export function parsePo(content: string): CatalogEntry[] {
110
124
  export function writePo(entries: CatalogEntry[]): string {
111
125
  const lines: string[] = [];
112
126
 
113
- // Active entries first, then obsolete
114
- const active = entries.filter((e) => !e.obsolete);
127
+ const manual = entries.filter((e) => e.manual);
128
+ const active = entries.filter((e) => !e.obsolete && !e.manual);
115
129
  const obsolete = entries.filter((e) => e.obsolete);
116
130
 
131
+ // Manual section
132
+ if (manual.length > 0) {
133
+ lines.push("# manual");
134
+ for (const entry of manual) {
135
+ lines.push(`msgid ${quoteString(entry.msgid)}`);
136
+ lines.push(`msgstr ${quoteString(entry.msgstr)}`);
137
+ lines.push("");
138
+ }
139
+ lines.push("# end manual");
140
+ lines.push("");
141
+ }
142
+
143
+ // Extracted entries
117
144
  for (const entry of active) {
118
145
  for (const loc of entry.locations) {
119
146
  lines.push(`#: ${loc}`);
@@ -124,6 +151,7 @@ export function writePo(entries: CatalogEntry[]): string {
124
151
  lines.push("");
125
152
  }
126
153
 
154
+ // Obsolete entries
127
155
  if (obsolete.length > 0) {
128
156
  lines.push("# Obsolete entries");
129
157
  lines.push("");
@@ -152,12 +180,18 @@ export function mergeCatalog(
152
180
  existingMap.set(entry.msgid, entry);
153
181
  }
154
182
 
183
+ // Preserve manual entries as-is
184
+ const manualEntries = existing.filter((e) => e.manual);
185
+ const manualIds = new Set(manualEntries.map((e) => e.msgid));
186
+
155
187
  const result: CatalogEntry[] = [];
156
188
  const seen = new Set<string>();
157
189
 
158
- // Process all extracted messages
190
+ // Process all extracted messages (skip those in manual section)
159
191
  for (const [msgid, locations] of extracted) {
160
192
  seen.add(msgid);
193
+ if (manualIds.has(msgid)) continue;
194
+
161
195
  const prev = existingMap.get(msgid);
162
196
 
163
197
  result.push({
@@ -171,7 +205,7 @@ export function mergeCatalog(
171
205
 
172
206
  // Mark removed entries as obsolete (keep their translations)
173
207
  for (const entry of existing) {
174
- if (!seen.has(entry.msgid) && !entry.obsolete && entry.msgstr) {
208
+ if (!seen.has(entry.msgid) && !entry.obsolete && !entry.manual && entry.msgstr) {
175
209
  result.push({
176
210
  ...entry,
177
211
  obsolete: true,
@@ -180,7 +214,11 @@ export function mergeCatalog(
180
214
  }
181
215
  }
182
216
 
183
- return result;
217
+ // Sort extracted entries alphabetically for deterministic output
218
+ result.sort((a, b) => a.msgid.localeCompare(b.msgid));
219
+
220
+ // Manual entries first, then sorted extracted, then obsolete
221
+ return [...manualEntries, ...result];
184
222
  }
185
223
 
186
224
  function extractQuoted(s: string): string {
@@ -18,7 +18,12 @@ export function compileCatalog(poPath: string): Record<string, string> {
18
18
  }
19
19
  }
20
20
 
21
- return result;
21
+ // Sort keys for deterministic JSON output
22
+ const sorted: Record<string, string> = {};
23
+ for (const key of Object.keys(result).sort()) {
24
+ sorted[key] = result[key];
25
+ }
26
+ return sorted;
22
27
  }
23
28
 
24
29
  /**
@@ -11,7 +11,8 @@ import {
11
11
  import { existsSync, mkdirSync } from "fs";
12
12
  import { join } from "path";
13
13
  import { readTranslationsConfig } from "../i18n";
14
- import type { BuildManifest, WorkspaceInfo } from "./shared";
14
+ import type { BuildManifest, ModuleEntry, WorkspaceInfo } from "./shared";
15
+ import type { ModuleAccess } from "@arcote.tech/platform";
15
16
 
16
17
  // ---------------------------------------------------------------------------
17
18
  // Types
@@ -23,6 +24,8 @@ export interface PlatformServerOptions {
23
24
  manifest: BuildManifest;
24
25
  /** Server context (from loadServerContext). If null, static-only mode. */
25
26
  context?: any;
27
+ /** Module access rules (from registry after loadServerContext). */
28
+ moduleAccess?: Map<string, ModuleAccess>;
26
29
  /** Path to SQLite database file */
27
30
  dbPath?: string;
28
31
  /** If true, enables SSE reload stream + mutable manifest (dev mode) */
@@ -144,20 +147,96 @@ function serveFile(
144
147
  });
145
148
  }
146
149
 
150
+ // ---------------------------------------------------------------------------
151
+ // Module access — signed URLs
152
+ // ---------------------------------------------------------------------------
153
+
154
+ const MODULE_SIG_SECRET = process.env.ARC_MODULE_SECRET ?? crypto.randomUUID();
155
+ const MODULE_SIG_TTL = 3600; // 1 hour
156
+
157
+ function signModuleUrl(filename: string): string {
158
+ const exp = Math.floor(Date.now() / 1000) + MODULE_SIG_TTL;
159
+ const hasher = new Bun.CryptoHasher("sha256");
160
+ hasher.update(`${filename}:${exp}:${MODULE_SIG_SECRET}`);
161
+ const sig = hasher.digest("hex").slice(0, 16);
162
+ return `/modules/${filename}?sig=${sig}&exp=${exp}`;
163
+ }
164
+
165
+ function verifyModuleSignature(filename: string, sig: string | null, exp: string | null): boolean {
166
+ if (!sig || !exp) return false;
167
+ if (Number(exp) < Date.now() / 1000) return false;
168
+ const hasher = new Bun.CryptoHasher("sha256");
169
+ hasher.update(`${filename}:${exp}:${MODULE_SIG_SECRET}`);
170
+ return hasher.digest("hex").slice(0, 16) === sig;
171
+ }
172
+
173
+ async function filterManifestForToken(
174
+ manifest: BuildManifest,
175
+ moduleAccessMap: Map<string, ModuleAccess>,
176
+ tokenPayload: any,
177
+ ): Promise<BuildManifest> {
178
+ const filtered: ModuleEntry[] = [];
179
+
180
+ for (const mod of manifest.modules) {
181
+ const access = moduleAccessMap.get(mod.name);
182
+
183
+ if (!access) {
184
+ // Public module — always include
185
+ filtered.push(mod);
186
+ continue;
187
+ }
188
+
189
+ // Protected module — check if token grants access
190
+ if (!tokenPayload) continue;
191
+
192
+ let granted = false;
193
+ for (const rule of access.rules) {
194
+ if (tokenPayload.tokenType === rule.token.name) {
195
+ granted = rule.check ? await rule.check(tokenPayload) : true;
196
+ if (granted) break;
197
+ }
198
+ }
199
+
200
+ if (granted) {
201
+ filtered.push({ ...mod, url: signModuleUrl(mod.file) } as any);
202
+ }
203
+ }
204
+
205
+ return { modules: filtered, buildTime: manifest.buildTime };
206
+ }
207
+
147
208
  // ---------------------------------------------------------------------------
148
209
  // Platform-specific HTTP handlers
149
210
  // ---------------------------------------------------------------------------
150
211
 
151
- function staticFilesHandler(ws: WorkspaceInfo, devMode: boolean): ArcHttpHandler {
212
+ function staticFilesHandler(
213
+ ws: WorkspaceInfo,
214
+ devMode: boolean,
215
+ moduleAccessMap: Map<string, ModuleAccess>,
216
+ ): ArcHttpHandler {
152
217
  return (_req, url, ctx) => {
153
218
  const path = url.pathname;
154
219
  if (path.startsWith("/shell/"))
155
220
  return serveFile(join(ws.shellDir, path.slice(7)), ctx.corsHeaders);
156
- if (path.startsWith("/modules/"))
157
- return serveFile(join(ws.modulesDir, path.slice(9)), {
221
+ if (path.startsWith("/modules/")) {
222
+ const fileWithParams = path.slice(9);
223
+ const filename = fileWithParams.split("?")[0];
224
+ const moduleName = filename.replace(/\.js$/, "");
225
+
226
+ // Check access for protected modules
227
+ if (moduleAccessMap.has(moduleName)) {
228
+ const sig = url.searchParams.get("sig");
229
+ const exp = url.searchParams.get("exp");
230
+ if (!verifyModuleSignature(filename, sig, exp)) {
231
+ return new Response("Forbidden", { status: 403, headers: ctx.corsHeaders });
232
+ }
233
+ }
234
+
235
+ return serveFile(join(ws.modulesDir, filename), {
158
236
  ...ctx.corsHeaders,
159
237
  "Cache-Control": devMode ? "no-cache" : "max-age=31536000,immutable",
160
238
  });
239
+ }
161
240
  if (path.startsWith("/locales/"))
162
241
  return serveFile(join(ws.arcDir, path.slice(1)), ctx.corsHeaders);
163
242
  if (path === "/styles.css")
@@ -185,10 +264,14 @@ function apiEndpointsHandler(
185
264
  ws: WorkspaceInfo,
186
265
  getManifest: () => BuildManifest,
187
266
  cm: ConnectionManager | null,
267
+ moduleAccessMap: Map<string, ModuleAccess>,
188
268
  ): ArcHttpHandler {
189
269
  return (_req, url, ctx) => {
190
- if (url.pathname === "/api/modules")
191
- return Response.json(getManifest(), { headers: ctx.corsHeaders });
270
+ if (url.pathname === "/api/modules") {
271
+ // Filter manifest based on token — protected modules only for authorized users
272
+ return filterManifestForToken(getManifest(), moduleAccessMap, ctx.tokenPayload)
273
+ .then((filtered) => Response.json(filtered, { headers: ctx.corsHeaders }));
274
+ }
192
275
 
193
276
  if (url.pathname === "/api/translations") {
194
277
  const config = readTranslationsConfig(ws.rootDir);
@@ -255,6 +338,7 @@ export async function startPlatformServer(
255
338
  opts: PlatformServerOptions,
256
339
  ): Promise<PlatformServer> {
257
340
  const { ws, port, devMode, context } = opts;
341
+ const moduleAccessMap = opts.moduleAccess ?? new Map();
258
342
  let manifest = opts.manifest;
259
343
  const getManifest = () => manifest;
260
344
 
@@ -285,9 +369,9 @@ export async function startPlatformServer(
285
369
 
286
370
  // Platform handlers only
287
371
  const handlers: ArcHttpHandler[] = [
288
- apiEndpointsHandler(ws, getManifest, null),
372
+ apiEndpointsHandler(ws, getManifest, null, moduleAccessMap),
289
373
  ...(devMode ? [devReloadHandler(sseClients)] : []),
290
- staticFilesHandler(ws, !!devMode),
374
+ staticFilesHandler(ws, !!devMode, moduleAccessMap),
291
375
  spaFallbackHandler(shellHtml),
292
376
  ];
293
377
 
@@ -331,9 +415,9 @@ export async function startPlatformServer(
331
415
  port,
332
416
  httpHandlers: [
333
417
  // Platform-specific handlers (checked AFTER arc handlers)
334
- apiEndpointsHandler(ws, getManifest, null),
418
+ apiEndpointsHandler(ws, getManifest, null, moduleAccessMap),
335
419
  ...(devMode ? [devReloadHandler(sseClients)] : []),
336
- staticFilesHandler(ws, !!devMode),
420
+ staticFilesHandler(ws, !!devMode, moduleAccessMap),
337
421
  spaFallbackHandler(shellHtml),
338
422
  ],
339
423
  onWsClose: (clientId) => cleanupClientSubs(clientId),
@@ -7,12 +7,13 @@ import {
7
7
  discoverPackages,
8
8
  isContextPackage,
9
9
  type BuildManifest,
10
+ type ModuleEntry,
10
11
  type WorkspacePackage,
11
12
  } from "../builder/module-builder";
12
13
 
13
14
  // Re-export for convenience
14
15
  export { buildPackages, buildStyles, isContextPackage };
15
- export type { BuildManifest, WorkspacePackage };
16
+ export type { BuildManifest, ModuleEntry, WorkspacePackage };
16
17
 
17
18
  // ---------------------------------------------------------------------------
18
19
  // Logging
@@ -212,36 +213,42 @@ export const { createPortal, flushSync } = ReactDOM;`,
212
213
  ["platform", "@arcote.tech/platform"],
213
214
  ];
214
215
 
215
- const arcEps: string[] = [];
216
+ const baseExternal = [
217
+ "react",
218
+ "react-dom",
219
+ "react/jsx-runtime",
220
+ "react/jsx-dev-runtime",
221
+ "react-dom/client",
222
+ ];
223
+ const allArcPkgs = arcEntries.map(([, pkg]) => pkg);
224
+
225
+ // Build each arc entry separately so it can import sibling arc packages
226
+ // as externals (resolved via import map) without circular self-reference.
216
227
  for (const [name, pkg] of arcEntries) {
217
228
  const f = join(tmpDir, `${name}.ts`);
218
229
  Bun.write(f, `export * from "${pkg}";\n`);
219
- arcEps.push(f);
220
- }
221
230
 
222
- const r2 = await Bun.build({
223
- entrypoints: arcEps,
224
- outdir: outDir,
225
- splitting: true,
226
- format: "esm",
227
- target: "browser",
228
- naming: "[name].[ext]",
229
- external: [
230
- "react",
231
- "react-dom",
232
- "react/jsx-runtime",
233
- "react/jsx-dev-runtime",
234
- "react-dom/client",
235
- ],
236
- define: {
237
- ONLY_SERVER: "false",
238
- ONLY_BROWSER: "true",
239
- ONLY_CLIENT: "true",
240
- },
241
- });
242
- if (!r2.success) {
243
- for (const l of r2.logs) console.error(l);
244
- throw new Error("Shell Arc build failed");
231
+ const r2 = await Bun.build({
232
+ entrypoints: [f],
233
+ outdir: outDir,
234
+ format: "esm",
235
+ target: "browser",
236
+ naming: "[name].[ext]",
237
+ external: [
238
+ ...baseExternal,
239
+ // Other arc packages are external (not self)
240
+ ...allArcPkgs.filter((p) => p !== pkg),
241
+ ],
242
+ define: {
243
+ ONLY_SERVER: "false",
244
+ ONLY_BROWSER: "true",
245
+ ONLY_CLIENT: "true",
246
+ },
247
+ });
248
+ if (!r2.success) {
249
+ for (const l of r2.logs) console.error(l);
250
+ throw new Error(`Shell build failed for ${pkg}`);
251
+ }
245
252
  }
246
253
 
247
254
  // Clean tmp
@@ -255,9 +262,9 @@ export const { createPortal, flushSync } = ReactDOM;`,
255
262
 
256
263
  export async function loadServerContext(
257
264
  packages: WorkspacePackage[],
258
- ): Promise<any | null> {
265
+ ): Promise<{ context: any | null; moduleAccess: Map<string, any> }> {
259
266
  const ctxPackages = packages.filter((p) => isContextPackage(p.packageJson));
260
- if (ctxPackages.length === 0) return null;
267
+ if (ctxPackages.length === 0) return { context: null, moduleAccess: new Map() };
261
268
 
262
269
  // Set globals for server context — framework packages (arc-auth etc.)
263
270
  // use these at runtime to tree-shake browser/server code paths.
@@ -278,6 +285,7 @@ export async function loadServerContext(
278
285
  // Pre-import platform so it's cached with this absolute path
279
286
  await import(platformEntry);
280
287
 
288
+ // Import context packages from server dist (has server-only code paths)
281
289
  for (const ctx of ctxPackages) {
282
290
  const serverDist = join(ctx.path, "dist", "server", "main", "index.js");
283
291
  if (!existsSync(serverDist)) {
@@ -292,6 +300,20 @@ export async function loadServerContext(
292
300
  }
293
301
  }
294
302
 
295
- const { getContext } = await import(platformEntry);
296
- return getContext() ?? null;
303
+ // Import non-context packages from source to capture module().protectedBy() metadata
304
+ const nonCtxPackages = packages.filter((p) => !isContextPackage(p.packageJson));
305
+ for (const pkg of nonCtxPackages) {
306
+ try {
307
+ await import(pkg.entrypoint);
308
+ } catch {
309
+ // Non-context packages may fail on server (React components etc.) — that's OK,
310
+ // module().protectedBy().build() runs synchronously before any rendering
311
+ }
312
+ }
313
+
314
+ const { getContext, getAllModuleAccess } = await import(platformEntry);
315
+ return {
316
+ context: getContext() ?? null,
317
+ moduleAccess: getAllModuleAccess(),
318
+ };
297
319
  }