suitescript-verify 0.1.0 → 0.3.0

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 CHANGED
@@ -9,15 +9,15 @@ npm install --save-dev suitescript-verify @oracle/suitecloud-cli
9
9
  npx suitescript-verify help
10
10
  ```
11
11
 
12
- `init` scaffolds `verification/probes`, `verification/sdf`, `docs/verification/results` and a committed `verification/.env` of layout keys, then prints a to-do list. It creates no credentials. `doctor` checks the SuiteCloud CLI, account, sandbox, certificates, passkey and project files, and says which failing steps are the maintainer's own; it exits 1 while any fails.
12
+ `init` scaffolds `verification/probes`, `verification/sdf`, your results folder and a committed `verification/.env` of layout keys, then prints a to-do list. It creates no credentials. The package imposes no layout for results: where results pages live is your choice, set as `SUITESCRIPT_VERIFY_RESULTS_DIR` in the committed `.env` (a path inside the project). `init` will not pick it for you: at a terminal it asks (suggesting `docs/verification/results`), and without one it needs `--results-dir <path>` (for example `init --results-dir docs/verification/results`). A project that already sets the key is not asked again, and a command that needs the folder without the key stops and names it. `doctor` checks the SuiteCloud CLI, account, sandbox, certificates, passkey and project files, and says which failing steps are the maintainer's own; it exits 1 while any fails.
13
13
 
14
14
  `certificate` generates the runner key pair (EC P-256) and the SDF key pair (RSA-4096) outside the project with `openssl` (`--keys-dir`, default `~/.suitescript-verify`), `passkey` and `env set NAME=value` save settings in the gitignored `verification/.env.local` (values are never printed; a different existing value is kept unless `--overwrite`), and `setup-ci` registers the SDF credentials with the SuiteCloud CLI.
15
15
 
16
16
  `setup-ci` prepares a pipeline: it registers the SDF credentials (auth id `suitescript-verify`) with your project's SuiteCloud CLI and passes the passkey to it through the environment, never an argument. On success it prints the `SUITESCRIPT_VERIFY_` names the pipeline must supply as its own secrets (account, client id, both certificate ids, both private key paths, passkey), never a value. It writes no file, no credential variable and no license variable, and it holds no credential itself: the pipeline provides them. Accepting Oracle's SuiteCloud license in the pipeline is yours.
17
17
 
18
- `deploy` generates the runner and an SDF project into `verification/sdf` (the generated `src/` and `package.json` there are yours to gitignore) from the RESTlet probes in `verification/probes`, then dry-runs it through your project's SuiteCloud CLI. A real deploy needs `--approved` and a person at a terminal who types `DEPLOY`. `cleanup` does the same with the runner's deployment switched off and needs `CLEANUP`. `--dir <path>` moves `verification/`.
18
+ `deploy` generates the runner and an SDF project into `verification/sdf` (the generated `src/` there is yours to gitignore) from the RESTlet probes in `verification/probes`, then dry-runs it through your project's SuiteCloud CLI. A real deploy needs `--approved` and a person at a terminal who types `DEPLOY`. `cleanup` does the same with the runner's deployment switched off and needs `CLEANUP`. `--dir <path>` moves `verification/`.
19
19
 
20
- `run <probe> [--draft]` runs one probe by name through the deployed runner. The name must be one of the probes in the folder: the runner only runs the fixed list generated into it and never code from a request. Output is scrubbed of the account, client and certificate ids, and refused if it still looks like an identifier. A probe with an `<probe>.expect.json` is compared with it and a difference exits 2 as drift. `--draft` writes `docs/verification/results/<probe>.md` (never over an existing page) with the test, NetSuite version and results, and empty explanation and decision sections. Probes must declare `@scriptType Restlet` and no `@writes`.
20
+ `run <probe> [--draft]` runs one probe by name through the deployed runner. The name must be one of the probes in the folder: the runner only runs the fixed list generated into it and never code from a request. Output is scrubbed of the account, client and certificate ids, and refused if it still looks like an identifier. A probe with an `<probe>.expect.json` is compared with it and a difference exits 2 as drift. `--draft` writes `<probe>.md` into your results folder (`SUITESCRIPT_VERIFY_RESULTS_DIR`; never over an existing page) with the test, NetSuite version and results, and empty explanation and decision sections. Probes must declare `@scriptType Restlet` and no `@writes`.
21
21
 
22
22
  A probe takes data by declaring parameters in its leading JSDoc block, one tag per parameter: `@param <name> <pattern>`. The name is lowercase-initial letters and digits (`recordType`); the pattern is a JavaScript regular expression (Unicode mode) that the whole value must match, so `[a-z_]+` cannot match `a;b`. Pass each value with `run <probe> --param name=value`. Every declared parameter is required and its value is always a string. The runner validates the parameters against the declarations before any request, rejecting an invalid, undeclared, missing or repeated one, and a probe that declares none takes none; the generated runner checks them again and hands the probe only declared strings in `run(params)`. A value goes in the JSON request body and is never evaluated or turned into code. A bad `@param` tag (no pattern, an invalid pattern, a duplicate name) stops `run` and `deploy`.
23
23
 
@@ -33,13 +33,13 @@ define(["N/record"], (record) => ({
33
33
  }));
34
34
  ```
35
35
 
36
- Every command that reaches the sandbox refuses an account that is not a sandbox (`digits_SBn`). The runner uses the script id `customscript_suitescript_verify_runner` and is limited to a role with the ID `customrole_suitescript_verify`, which you create in the sandbox.
36
+ Every command that reaches the sandbox refuses an account that is not a sandbox (`digits_SBn`). The runner uses the script id `customscript_suitescript_verify_runner` and is limited to a role with the ID `customrole_suitescript_verify`, which you create in the sandbox. A project that names its own role sets `SUITESCRIPT_VERIFY_ROLE_ID` (for example `SUITESCRIPT_VERIFY_ROLE_ID=customrole_my_runner`); the generated SDF project uses it in the manifest and the runner. Like every `SUITESCRIPT_VERIFY_` setting it layers from the committed `verification/.env`, the gitignored `.env.local` and the process environment. The value must match `^customrole_[a-z0-9_]+$`; a bad value stops every command that reaches the sandbox, and the error names the key, never the value. `doctor` reports the id in use.
37
37
 
38
- See [docs/verification/README.md](docs/verification/README.md) and [docs/verification/lessons.md](docs/verification/lessons.md).
38
+ The verification method and the lessons learned running probes are in the repository, not the package: see [the verification guide](https://github.com/nemchik/suitescript-verify/blob/main/docs/verification/README.md) and [the lessons](https://github.com/nemchik/suitescript-verify/blob/main/docs/verification/lessons.md).
39
39
 
40
40
  ## Development
41
41
 
42
- You need the Node.js and npm versions in `package.json` `devEngines`.
42
+ You need the Node.js and npm versions in `package.json` `devEngines` (mirrored in `.nvmrc`). `npm pack` and `npm test` refuse any other Node version.
43
43
 
44
44
  ```sh
45
45
  npm install
package/dist/cli.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import { randomBytes } from "node:crypto";
2
+ import { createInterface } from "node:readline/promises";
2
3
  import { certificate } from "./certificate.js";
3
- import { loadConfig } from "./config.js";
4
+ import { loadConfig, resultsDirGuidance, resultsDirKey, suggestedResultsDir, } from "./config.js";
4
5
  import { formatChecks, names, runChecks } from "./doctor.js";
5
6
  import { envNames } from "./envfile.js";
6
7
  import { init } from "./init.js";
@@ -10,7 +11,7 @@ import { confirmWrite, flagValue, positionals, requireSandbox, saveEnv, suiteclo
10
11
  export const USAGE = `Usage: suitescript-verify <command> [options]
11
12
 
12
13
  Commands:
13
- init Scaffold the folders and .env, and print a to-do list
14
+ init [--results-dir <path>] Scaffold the folders and .env, and print a to-do list; the results folder is yours to choose (suggested: docs/verification/results)
14
15
  doctor Check the setup; each failing step says who does it
15
16
  certificate [--only runner|sdf] [--keys-dir <path>] [--overwrite | --keep]
16
17
  Generate the key pairs outside the project
@@ -53,8 +54,23 @@ function doctor(args) {
53
54
  console.log(formatChecks(checks));
54
55
  return config.problems.length === 0 && checks.every((c) => c.ok) ? 0 : 1;
55
56
  }
56
- function initCommand(args) {
57
- console.log(init(process.cwd(), reporting(args)));
57
+ /** The results folder `init` was told to use: the flag, or a prompt at a terminal when the key is not set. Never a silent default. */
58
+ async function chooseResultsDir(config, args) {
59
+ const flagged = flagValue(args, "--results-dir");
60
+ // Already configured: the flag is passed on only so `init` can say it was ignored.
61
+ if (config.resultsDir !== undefined || flagged !== undefined)
62
+ return flagged;
63
+ if (!process.stdin.isTTY) {
64
+ throw new UsageError(`${resultsDirKey} is not set. ${resultsDirGuidance} Nothing was created.`);
65
+ }
66
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
67
+ const answer = await rl.question(`Where should results pages live in this project? [${suggestedResultsDir}] `);
68
+ rl.close();
69
+ return answer.trim() || suggestedResultsDir;
70
+ }
71
+ async function initCommand(args) {
72
+ const config = reporting(args);
73
+ console.log(init(process.cwd(), config, await chooseResultsDir(config, args)));
58
74
  return 0;
59
75
  }
60
76
  async function deploy(args) {
@@ -64,6 +80,7 @@ async function deploy(args) {
64
80
  const { files, slugs } = buildSdfProject(config.sdfDir, config.probesDir, {
65
81
  deployed: true,
66
82
  extraTags: config.extraTags,
83
+ roleId: config.roleId,
67
84
  });
68
85
  console.log(`Probes in the runner: ${slugs.join(", ") || "(none)"}`);
69
86
  console.log(`Files to upload: ${files.join(", ")}`);
@@ -92,6 +109,7 @@ async function cleanup(args) {
92
109
  buildSdfProject(config.sdfDir, config.probesDir, {
93
110
  deployed: false,
94
111
  extraTags: config.extraTags,
112
+ roleId: config.roleId,
95
113
  });
96
114
  return suitecloud(project, config, [
97
115
  "project:deploy",
@@ -183,7 +201,7 @@ export async function main(args) {
183
201
  if (command === "doctor")
184
202
  return doctor(rest);
185
203
  if (command === "init")
186
- return initCommand(rest);
204
+ return await initCommand(rest);
187
205
  throw new UsageError(`Unknown command "${command}".\n\n${USAGE}`);
188
206
  }
189
207
  catch (error) {
package/dist/config.js CHANGED
@@ -8,8 +8,17 @@ export const layoutKeys = [
8
8
  "SUITESCRIPT_VERIFY_RESULTS_DIR",
9
9
  "SUITESCRIPT_VERIFY_SDF_DIR",
10
10
  ];
11
+ export const resultsDirKey = "SUITESCRIPT_VERIFY_RESULTS_DIR";
12
+ /** What `init` suggests for the results folder; it is never applied without the project's say-so. */
13
+ export const suggestedResultsDir = "docs/verification/results";
14
+ /** What every command says when the results folder is not chosen: one sentence, so the commands cannot drift apart. */
15
+ export const resultsDirGuidance = `Choose where results pages live in your project, then run suitescript-verify init --results-dir <path> (${suggestedResultsDir} is the suggested value) or set ${resultsDirKey} in the committed .env.`;
11
16
  /** The committed `.env` may also register extra probe tags: a comma-separated list of tag names, not a path. */
12
17
  export const extraTagsKey = "SUITESCRIPT_VERIFY_EXTRA_TAGS";
18
+ /** The committed `.env` may also name the runner role: a NetSuite custom role id. */
19
+ export const roleIdKey = "SUITESCRIPT_VERIFY_ROLE_ID";
20
+ export const defaultRoleId = "customrole_suitescript_verify";
21
+ const roleIdPattern = /^customrole_[a-z0-9_]+$/;
13
22
  const tagName = /^[A-Za-z][A-Za-z0-9_-]*$/;
14
23
  /** The tag names in a registration value, without the `@`; blank entries are dropped. */
15
24
  export const parseTagList = (value) => value
@@ -41,7 +50,7 @@ function readEnvFile(path) {
41
50
  ? parseEnv(readFileSync(path, "utf8"))
42
51
  : new Map();
43
52
  }
44
- const insideProject = (project, path) => {
53
+ export const insideProject = (project, path) => {
45
54
  const rel = relative(project, resolve(project, path));
46
55
  return !isAbsolute(rel) && rel.split(/[\\/]/)[0] !== "..";
47
56
  };
@@ -49,11 +58,19 @@ const insideProject = (project, path) => {
49
58
  const looksSecret = (value) => /-----BEGIN /.test(value) ||
50
59
  /^eyJ[\w-]+\.[\w-]+\.[\w-]+$/.test(value) ||
51
60
  /^[A-Za-z0-9+_=-]{32,}$/.test(value);
61
+ const roleIdProblem = (file) => ({
62
+ message: `${roleIdKey} in ${file} is not a NetSuite custom role id like customrole_suitescript_verify (lowercase letters, digits and underscores after customrole_).`,
63
+ });
52
64
  /** Checks the committed `.env` against the allowlist. Messages name the key, never the value. */
53
65
  function checkCommitted(project, committed, file) {
54
66
  const problems = [];
55
67
  for (const [key, value] of committed) {
56
- if (key === extraTagsKey) {
68
+ if (key === roleIdKey) {
69
+ // The pattern is the whole check: a valid id may be long enough to look like a token.
70
+ if (!roleIdPattern.test(value))
71
+ problems.push(roleIdProblem(file));
72
+ }
73
+ else if (key === extraTagsKey) {
57
74
  if (!parseTagList(value).every((name) => tagName.test(name))) {
58
75
  problems.push({
59
76
  message: `${key} in ${file} is not a comma-separated list of tag names like flavor,other.`,
@@ -67,7 +84,7 @@ function checkCommitted(project, committed, file) {
67
84
  }
68
85
  else if (!layoutKeys.includes(key)) {
69
86
  problems.push({
70
- message: `${key} in ${file} is not a layout key. The committed file holds layout only (${[...layoutKeys, extraTagsKey].join(", ")}); put anything else in .env.local or the process environment.`,
87
+ message: `${key} in ${file} is not a layout key. The committed file holds layout only (${[...layoutKeys, extraTagsKey, roleIdKey].join(", ")}); put anything else in .env.local or the process environment.`,
71
88
  });
72
89
  }
73
90
  else if (!insideProject(project, value)) {
@@ -125,6 +142,13 @@ export function loadConfig(project, dir, processEnv = process.env) {
125
142
  }
126
143
  }
127
144
  const path = (key, fallback) => resolve(project, values[key] || fallback);
145
+ const roleId = values[roleIdKey] || defaultRoleId;
146
+ // The committed file reports its own bad value; this covers .env.local and the environment.
147
+ const committedRole = committed.get(roleIdKey);
148
+ const roleProblems = roleIdPattern.test(roleId) ||
149
+ (committedRole !== undefined && !roleIdPattern.test(committedRole))
150
+ ? []
151
+ : [roleIdProblem(".env.local or the environment")];
128
152
  const shown = relative(project, envFile).replaceAll("\\", "/");
129
153
  return {
130
154
  extraTags: parseTagList(values[extraTagsKey] ?? ""),
@@ -132,8 +156,12 @@ export function loadConfig(project, dir, processEnv = process.env) {
132
156
  problems: [
133
157
  ...checkCommitted(project, committed, shown),
134
158
  ...checkLocalIgnored(project, localFile),
159
+ ...roleProblems,
135
160
  ],
136
- resultsDir: path("SUITESCRIPT_VERIFY_RESULTS_DIR", "docs/verification/results"),
161
+ resultsDir: values[resultsDirKey]
162
+ ? resolve(project, values[resultsDirKey])
163
+ : undefined,
164
+ roleId,
137
165
  root,
138
166
  sdfDir: path("SUITESCRIPT_VERIFY_SDF_DIR", join(root, "sdf")),
139
167
  values,
package/dist/doctor.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import { createPrivateKey } from "node:crypto";
2
2
  import { existsSync, readFileSync } from "node:fs";
3
3
  import { join } from "node:path";
4
+ import { insideProject, resultsDirGuidance, resultsDirKey, } from "./config.js";
4
5
  export const names = {
5
6
  account: "SUITESCRIPT_VERIFY_ACCOUNT",
6
7
  certificateId: "SUITESCRIPT_VERIFY_CERTIFICATE_ID",
@@ -76,10 +77,13 @@ export function runChecks(project, config, env = process.env) {
76
77
  // A malformed project.json fails the step.
77
78
  }
78
79
  }
79
- const absent = [config.probesDir, config.resultsDir, config.sdfDir].filter((dir) => !existsSync(dir));
80
+ add(`The results folder is set, inside the project (${resultsDirKey})`, config.resultsDir !== undefined &&
81
+ insideProject(project, config.resultsDir), "maintainer", resultsDirGuidance);
82
+ const absent = [config.probesDir, config.resultsDir, config.sdfDir].filter((dir) => dir !== undefined && !existsSync(dir));
80
83
  add("Project files: the probe, results and SDF folders exist and the SDF project has a default auth id", absent.length === 0 && authId !== "", absent.length > 0 ? "agent" : "maintainer", absent.length > 0
81
84
  ? "Run suitescript-verify init to create the missing folders."
82
85
  : "Run account:setup:ci from the SDF folder once the steps above pass, so project.json gets a default auth id.");
86
+ add(`Runner role id: ${config.roleId}`, true, "agent", "");
83
87
  return checks;
84
88
  }
85
89
  export function formatChecks(checks) {
package/dist/init.js CHANGED
@@ -1,35 +1,81 @@
1
- import { existsSync, mkdirSync, writeFileSync } from "node:fs";
2
- import { dirname, join, relative } from "node:path";
1
+ import { appendFileSync, existsSync, mkdirSync, readFileSync, writeFileSync, } from "node:fs";
2
+ import { dirname, join, relative, resolve } from "node:path";
3
+ import { insideProject, resultsDirKey } from "./config.js";
4
+ import { UsageError } from "./shared.js";
5
+ /** A path relative to the project, with forward slashes, for messages and the committed `.env`. */
6
+ const shown = (project, path) => relative(project, path).replaceAll("\\", "/");
3
7
  /**
4
- * Scaffolds the folders and a committed `.env` of layout keys, then returns
5
- * the to-do list for the person. Creates no credential and installs nothing.
8
+ * Which folder holds results: the one the configuration already sets, else the
9
+ * one the project just chose. `ignoredNote` is non-empty when a choice was
10
+ * given but the key was already set. Refuses a choice outside the project
11
+ * before anything is created.
6
12
  */
7
- export function init(project, config) {
8
- const rel = (path) => relative(project, path).replaceAll("\\", "/");
9
- const dirs = [config.probesDir, config.resultsDir, config.sdfDir];
10
- for (const dir of dirs)
11
- mkdirSync(dir, { recursive: true });
13
+ function pickResultsDir(project, config, chosenResultsDir) {
14
+ const chosen = chosenResultsDir === undefined
15
+ ? undefined
16
+ : resolve(project, chosenResultsDir);
17
+ if (chosen !== undefined && !insideProject(project, chosen)) {
18
+ throw new UsageError(`${resultsDirKey} must be a path inside the project, because the committed .env holds layout only. Nothing was created.`);
19
+ }
20
+ if (config.resultsDir !== undefined) {
21
+ const ignoredNote = chosen !== undefined && chosen !== config.resultsDir
22
+ ? `${resultsDirKey} is already set to ${shown(project, config.resultsDir)}, so --results-dir ${shown(project, chosen)} was ignored. Change the key itself to move the results folder.\n\n`
23
+ : "";
24
+ return { alreadySet: true, dir: config.resultsDir, ignoredNote };
25
+ }
26
+ if (chosen === undefined) {
27
+ throw new Error("init needs a results folder; the caller must choose one.");
28
+ }
29
+ return { alreadySet: false, dir: chosen, ignoredNote: "" };
30
+ }
31
+ /**
32
+ * Writes the layout keys to the committed `.env`: a new file with all of them,
33
+ * or, for an existing one, only the results key when it is missing. Returns
34
+ * whether the file was created.
35
+ */
36
+ function writeLayoutEnv(project, config, resultsDir) {
12
37
  const envFile = join(config.root, ".env");
13
- const created = !existsSync(envFile);
14
- if (created) {
38
+ const results = `${resultsDirKey}=${shown(project, resultsDir.dir)}`;
39
+ if (!existsSync(envFile)) {
15
40
  mkdirSync(dirname(envFile), { recursive: true });
16
41
  writeFileSync(envFile, [
17
42
  "# Layout only: this file is committed. Credentials go in .env.local, which is gitignored.",
18
- `SUITESCRIPT_VERIFY_PROBES_DIR=${rel(config.probesDir)}`,
19
- `SUITESCRIPT_VERIFY_RESULTS_DIR=${rel(config.resultsDir)}`,
20
- `SUITESCRIPT_VERIFY_SDF_DIR=${rel(config.sdfDir)}`,
43
+ `SUITESCRIPT_VERIFY_PROBES_DIR=${shown(project, config.probesDir)}`,
44
+ results,
45
+ `SUITESCRIPT_VERIFY_SDF_DIR=${shown(project, config.sdfDir)}`,
21
46
  "",
22
47
  ].join("\n"));
48
+ return true;
49
+ }
50
+ if (!resultsDir.alreadySet) {
51
+ const text = readFileSync(envFile, "utf8");
52
+ const lead = text === "" || text.endsWith("\n") ? "" : "\n";
53
+ appendFileSync(envFile, `${lead}${results}\n`);
23
54
  }
24
- const local = rel(join(config.root, ".env.local"));
55
+ return false;
56
+ }
57
+ /**
58
+ * Scaffolds the folders and a committed `.env` of layout keys, then returns
59
+ * the to-do list for the person. Creates no credential and installs nothing.
60
+ * `chosenResultsDir` is the project's own choice for the results folder; it is
61
+ * used only when the configuration does not already set one.
62
+ */
63
+ export function init(project, config, chosenResultsDir) {
64
+ const results = pickResultsDir(project, config, chosenResultsDir);
65
+ const dirs = [config.probesDir, results.dir, config.sdfDir];
66
+ for (const dir of dirs)
67
+ mkdirSync(dir, { recursive: true });
68
+ const created = writeLayoutEnv(project, config, results);
69
+ const envFile = shown(project, join(config.root, ".env"));
70
+ const local = shown(project, join(config.root, ".env.local"));
25
71
  return [
26
- `${created ? "Created" : "Kept"} ${rel(envFile)} and the folders ${dirs.map(rel).join(", ")}. No credentials were created.`,
72
+ `${results.ignoredNote}${created ? "Created" : "Kept"} ${envFile} and the folders ${dirs.map((dir) => shown(project, dir)).join(", ")}. No credentials were created.`,
27
73
  "",
28
74
  "To do (each step is yours, the maintainer's):",
29
75
  "1. Install the SuiteCloud CLI: npm install --save-dev @oracle/suitecloud-cli. Read Oracle's license and accept it yourself; this package never does.",
30
76
  `2. Add ${local} to .gitignore before saving any value: it holds credentials.`,
31
77
  "3. In a sandbox, create the integration record, the role and the certificate mappings, and generate the private keys outside the repository.",
32
- `4. Save the account, client id, certificate ids, key paths and a passkey in ${local} (see docs/verification/README.md).`,
78
+ `4. Save the account, client id, certificate ids, key paths and a passkey in ${local}. Setup is described in the package README: https://github.com/nemchik/suitescript-verify#readme`,
33
79
  "5. Run suitescript-verify doctor until every step passes.",
34
80
  ].join("\n");
35
81
  }
package/dist/run.js CHANGED
@@ -3,7 +3,7 @@ import { join, relative } from "node:path";
3
3
  import { clientAssertion, restletUrl, scrub, tokenUrl } from "./netsuite.js";
4
4
  import { anchored, isRestlet, isSlug, listProbes, matchesExpect, read, readExpect, tagProblems, } from "./probes.js";
5
5
  import { resultsDraft } from "./results.js";
6
- import { positionals, requireSandbox, UsageError } from "./shared.js";
6
+ import { positionals, requireResultsDir, requireSandbox, UsageError, } from "./shared.js";
7
7
  /** Every `--param name=value` in the arguments, in order. The value may start with anything. */
8
8
  function suppliedParams(args) {
9
9
  const out = [];
@@ -63,6 +63,10 @@ function validateParams(probe, args) {
63
63
  * code: the runner only knows the probes generated into it by `deploy`.
64
64
  */
65
65
  export async function runProbe(project, config, args) {
66
+ // A missing results key is the project's own layout choice: report it before the credentials.
67
+ const resultsDir = args.includes("--draft")
68
+ ? requireResultsDir(config)
69
+ : undefined;
66
70
  const creds = requireSandbox(config);
67
71
  const name = positionals(args)[0] ?? "";
68
72
  const probes = listProbes(config.probesDir, config.extraTags);
@@ -73,7 +77,7 @@ export async function runProbe(project, config, args) {
73
77
  throw new UsageError(`Unknown probe "${name}". Known: ${probes.map((p) => p.slug).join(", ") || "(none)"}`);
74
78
  }
75
79
  if (!isRestlet(probe)) {
76
- throw new UsageError(`${probe.slug} needs a ${probe.scriptType || "(undeclared)"} script. The runner only runs RESTlet probes (declare @scriptType Restlet); ask the user to run it by hand (docs/verification/README.md).`);
80
+ throw new UsageError(`${probe.slug} needs a ${probe.scriptType || "(undeclared)"} script. The runner only runs RESTlet probes (declare @scriptType Restlet); ask the user to run it by hand in a sandbox console.`);
77
81
  }
78
82
  if (probe.writes) {
79
83
  throw new UsageError(`${probe.slug} declares @writes, which needs the user's separate approval. That path is not automated.`);
@@ -156,9 +160,9 @@ export async function runProbe(project, config, args) {
156
160
  console.log(`\nDRIFT: the output differs from ${probe.slug}.expect.json. The platform changed, or the expectation is wrong.`);
157
161
  }
158
162
  }
159
- if (args.includes("--draft")) {
163
+ if (resultsDir !== undefined) {
160
164
  const probeFile = join(config.probesDir, `${probe.slug}.js`);
161
- const page = join(config.resultsDir, `${probe.slug}.md`);
165
+ const page = join(resultsDir, `${probe.slug}.md`);
162
166
  const shown = relative(project, page).replaceAll("\\", "/");
163
167
  if (existsSync(page)) {
164
168
  console.log(`\n${shown} already exists; not overwriting.`);
@@ -169,7 +173,7 @@ export async function runProbe(project, config, args) {
169
173
  output: body.result,
170
174
  probe,
171
175
  probeFile,
172
- resultsDir: config.resultsDir,
176
+ resultsDir,
173
177
  scriptSource: read(probeFile),
174
178
  version: body.version ?? "unknown",
175
179
  }), known);
package/dist/sdf.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
2
2
  import { join } from "node:path";
3
+ import { defaultRoleId } from "./config.js";
3
4
  import { fileCabinetFolder } from "./netsuite.js";
4
5
  import { isRestlet, listProbes, read, tagProblems, } from "./probes.js";
5
6
  import { UsageError } from "./shared.js";
@@ -24,7 +25,7 @@ export function generateRunner(probes, source) {
24
25
  * the SDF folder. Only RESTlet probes go in: the runner is a RESTlet. With
25
26
  * `deployed: false` the runner's deployment is switched off (cleanup).
26
27
  */
27
- export function buildSdfProject(sdfDir, probesDir, { deployed, extraTags = [], }) {
28
+ export function buildSdfProject(sdfDir, probesDir, { deployed, extraTags = [], roleId = defaultRoleId, }) {
28
29
  const probes = listProbes(probesDir, extraTags).filter(isRestlet);
29
30
  const bad = probes.filter((probe) => tagProblems(probe).length > 0);
30
31
  if (bad.length > 0) {
@@ -39,9 +40,11 @@ export function buildSdfProject(sdfDir, probesDir, { deployed, extraTags = [], }
39
40
  mkdirSync(join(src, "Objects"), { recursive: true });
40
41
  writeFileSync(join(sdfDir, "package.json"), template("sdf/package.json"));
41
42
  writeFileSync(join(sdfDir, "suitecloud.config.js"), template("sdf/suitecloud.config.js"));
42
- writeFileSync(join(src, "manifest.xml"), template("sdf/manifest.xml"));
43
+ writeFileSync(join(src, "manifest.xml"), template("sdf/manifest.xml").replaceAll("%%ROLE_ID%%", roleId));
43
44
  writeFileSync(join(src, "deploy.xml"), template("sdf/deploy.xml"));
44
- writeFileSync(join(src, "Objects", "customscript_suitescript_verify_runner.xml"), template("sdf/runner.xml").replace("%%DEPLOYED%%", deployed ? "T" : "F"));
45
+ writeFileSync(join(src, "Objects", "customscript_suitescript_verify_runner.xml"), template("sdf/runner.xml")
46
+ .replace("%%DEPLOYED%%", deployed ? "T" : "F")
47
+ .replaceAll("%%ROLE_ID%%", roleId));
45
48
  const slugs = probes.map((probe) => probe.slug);
46
49
  const files = ["runner.js"];
47
50
  writeFileSync(join(folder, "runner.js"), generateRunner(probes, template("runner.template.js")));
package/dist/shared.js CHANGED
@@ -2,12 +2,19 @@ import { spawnSync } from "node:child_process";
2
2
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
3
3
  import { dirname, join } from "node:path";
4
4
  import { createInterface } from "node:readline/promises";
5
+ import { resultsDirGuidance, resultsDirKey } from "./config.js";
5
6
  import { isSandboxAccount, names } from "./doctor.js";
6
7
  import { applyEnvUpdate, planEnvUpdate } from "./envfile.js";
7
8
  /** A refusal or mistake to report to the person; the message holds no secret. */
8
9
  export class UsageError extends Error {
9
10
  }
10
- const valueFlags = ["--dir", "--keys-dir", "--only", "--param"];
11
+ const valueFlags = [
12
+ "--dir",
13
+ "--keys-dir",
14
+ "--only",
15
+ "--param",
16
+ "--results-dir",
17
+ ];
11
18
  /** The value after a flag, when given. */
12
19
  export function flagValue(args, flag) {
13
20
  const at = args.indexOf(flag);
@@ -110,6 +117,13 @@ export async function saveEnv(config, args, entries) {
110
117
  console.log(`Kept the existing ${name}. To replace it, ask the user, then rerun with --overwrite.`);
111
118
  }
112
119
  }
120
+ /** The results folder, or a refusal naming the key: the package imposes no layout for results. */
121
+ export function requireResultsDir(config) {
122
+ if (config.resultsDir === undefined) {
123
+ throw new UsageError(`${resultsDirKey} is not set. ${resultsDirGuidance}`);
124
+ }
125
+ return config.resultsDir;
126
+ }
113
127
  /** The runner credentials, or a refusal: every command that reaches the sandbox needs a sandbox account. */
114
128
  export function requireSandbox(config) {
115
129
  const v = config.values;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "suitescript-verify",
3
- "version": "0.1.0",
3
+ "version": "0.3.0",
4
4
  "description": "Run probes in a NetSuite sandbox to verify what SuiteScript modules do, with guardrails for agents.",
5
5
  "keywords": [
6
6
  "suitescript",
@@ -50,7 +50,7 @@
50
50
  "@types/node": "24.19.2",
51
51
  "git-pull-run": "2.1.0",
52
52
  "lefthook": "2.2.2",
53
- "skills": "1.7.6",
53
+ "skills": "1.8.0",
54
54
  "typescript": "7.0.2"
55
55
  },
56
56
  "peerDependencies": {
@@ -6,7 +6,7 @@
6
6
  <feature required="true">SERVERSIDESCRIPTING</feature>
7
7
  </features>
8
8
  <objects>
9
- <object>customrole_suitescript_verify</object>
9
+ <object>%%ROLE_ID%%</object>
10
10
  </objects>
11
11
  </dependencies>
12
12
  </manifest>
@@ -7,7 +7,7 @@
7
7
  <scriptdeployments>
8
8
  <scriptdeployment scriptid="customdeploy_suitescript_verify_runner">
9
9
  <allroles>F</allroles>
10
- <audslctrole>[customrole_suitescript_verify]</audslctrole>
10
+ <audslctrole>[%%ROLE_ID%%]</audslctrole>
11
11
  <isdeployed>%%DEPLOYED%%</isdeployed>
12
12
  <loglevel>DEBUG</loglevel>
13
13
  <status>RELEASED</status>