@omega.js/desktop 0.53.0 → 0.54.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 +38 -38
- package/dist/cli-run.js +4 -1
- package/dist/cli.js +2 -2
- package/dist/commands/cdp/client.js +1 -1
- package/dist/commands/cdp.js +1 -1
- package/dist/commands/clean.js +2 -3
- package/dist/commands/dev.js +25 -0
- package/dist/commands/lib/ensure-target.js +12 -17
- package/dist/commands/lib/migrate.js +17 -0
- package/dist/commands/logs.js +1 -1
- package/dist/commands/release.js +1 -1
- package/dist/commands/test.js +4 -4
- package/dist/commands/update.js +5 -4
- package/dist/defaults/.github/workflows/build.yml +18 -18
- package/dist/defaults/_.gitignore +0 -2
- package/dist/defaults/_mas/README.md +3 -3
- package/dist/defaults/config/certs/README.md +1 -1
- package/dist/defaults/config/omega.json5 +36 -36
- package/dist/defaults/docs/README.md +3 -3
- package/dist/defaults/gulpfile.js +1 -1
- package/dist/defaults/hooks/build/post.js +1 -1
- package/dist/defaults/hooks/build/pre.js +1 -1
- package/dist/defaults/hooks/notarize/post.js +2 -2
- package/dist/defaults/hooks/release/post.js +1 -1
- package/dist/defaults/hooks/release/pre.js +1 -1
- package/dist/defaults/src/assets/scss/pages/about.scss +1 -1
- package/dist/defaults/src/assets/scss/pages/main.scss +1 -1
- package/dist/defaults/src/assets/scss/pages/settings.scss +1 -1
- package/dist/defaults/src/integrations/context-menu/index.js +11 -11
- package/dist/defaults/src/integrations/menu/index.js +5 -5
- package/dist/defaults/src/integrations/tray/index.js +9 -9
- package/dist/defaults/src/main.js +2 -2
- package/dist/defaults/src/preload.js +1 -1
- package/dist/defaults/test/README.md +3 -3
- package/dist/defaults/test/_init.js +1 -1
- package/dist/gulp/tasks/audit.js +5 -8
- package/dist/lib/restart-manager/index.js +1 -1
- package/dist/lib/restart-manager/install.js +1 -1
- package/dist/lib/restart-manager/protocol.js +1 -1
- package/dist/main.js +4 -3
- package/dist/preload.js +1 -1
- package/dist/test/suites/build/audit.test.js +20 -7
- package/dist/test/suites/build/build-workflow-jobs.test.js +2 -2
- package/dist/test/suites/build/cli.test.js +28 -0
- package/dist/test/suites/build/defaults-em-dash.test.js +22 -0
- package/dist/test/suites/build/defaults-scaffold.test.js +19 -5
- package/dist/test/suites/build/deploy-direct.test.js +7 -5
- package/dist/test/suites/build/deploy-dispatch.test.js +2 -1
- package/dist/test/suites/build/deploy-hook.test.js +4 -2
- package/dist/test/suites/build/dev-verb.test.js +67 -0
- package/dist/test/suites/build/ensure-target.test.js +11 -3
- package/dist/test/suites/build/merge-line-files.test.js +6 -6
- package/dist/test/suites/build/migrate.test.js +29 -0
- package/dist/test/suites/build/project-scripts-deps.test.js +6 -10
- package/dist/test/suites/build/runner-env-write.test.js +73 -0
- package/dist/test/suites/build/runner.test.js +9 -8
- package/dist/test/suites/build/setup-scripts.test.js +27 -0
- package/dist/test/suites/build/validate-config.test.js +13 -2
- package/dist/test/suites/build/verb-logs.test.js +20 -0
- package/dist/test/suites/renderer/window-desktop-surface.test.js +1 -1
- package/dist/utils/build-pipeline.js +4 -4
- package/dist/utils/runner-env.js +13 -28
- package/dist/vendor/config/company.js +46 -14
- package/dist/vendor/config/defaults.js +30 -7
- package/dist/vendor/config/edit.js +25 -3
- package/dist/vendor/config/env-delivery.js +1 -1
- package/dist/vendor/config/env-schema.js +3 -6
- package/dist/vendor/config/env.js +34 -22
- package/dist/vendor/config/index.js +13 -17
- package/dist/vendor/config/load.js +15 -7
- package/dist/vendor/config/repo.js +10 -27
- package/dist/vendor/config/schema-client.js +64 -0
- package/dist/vendor/config/schema-cloud.js +38 -0
- package/dist/vendor/config/schema-manager.js +118 -0
- package/dist/vendor/config/schema-overrides.js +68 -0
- package/dist/vendor/config/schema.js +99 -152
- package/dist/vendor/config/validate.js +97 -77
- package/dist/vendor/devkit/agents-md.js +233 -0
- package/dist/vendor/devkit/attach-log-file.js +15 -1
- package/dist/vendor/devkit/ci-workflows.js +30 -30
- package/dist/vendor/devkit/cli-router.js +13 -7
- package/dist/vendor/devkit/defaults-engine.js +9 -43
- package/dist/vendor/devkit/deploy-snapshot.js +44 -9
- package/dist/vendor/devkit/env-lines.js +183 -0
- package/dist/vendor/devkit/local.js +62 -10
- package/dist/vendor/devkit/lockfile.js +32 -13
- package/dist/vendor/devkit/logger.js +7 -2
- package/dist/vendor/devkit/merge-line-files.js +219 -176
- package/dist/vendor/devkit/omega-bin.js +208 -111
- package/dist/vendor/devkit/preludes/docs-sync.js +52 -0
- package/dist/vendor/devkit/preludes/index.js +1 -0
- package/dist/vendor/devkit/target-picker.js +45 -0
- package/dist/vendor/devkit/test/dashed-files.js +37 -0
- package/dist/vendor/devkit/test/run-verb-under-tee.js +71 -0
- package/dist/vendor/devkit/update.js +15 -15
- package/dist/vendor/devkit/verb-scripts.js +40 -0
- package/dist/vendor/devkit/verbs.js +170 -0
- package/package.json +18 -24
- package/dist/commands/install.js +0 -37
- package/dist/defaults/AGENTS.md +0 -119
- package/dist/defaults/CLAUDE.md +0 -1
- package/dist/vendor/config/env-retired.js +0 -137
- package/dist/vendor/config/retired-keys.js +0 -635
- package/docs/analytics.md +0 -140
- package/docs/app-state.md +0 -92
- package/docs/audit.md +0 -69
- package/docs/auth.md +0 -284
- package/docs/auto-updater.md +0 -243
- package/docs/boot-sequence.md +0 -44
- package/docs/build-system.md +0 -169
- package/docs/cdp-debugging.md +0 -169
- package/docs/common-mistakes.md +0 -21
- package/docs/config-schema.md +0 -120
- package/docs/context-menu.md +0 -112
- package/docs/context.md +0 -81
- package/docs/css.md +0 -84
- package/docs/deep-link.md +0 -186
- package/docs/environment-detection.md +0 -112
- package/docs/fontawesome.md +0 -109
- package/docs/hooks.md +0 -89
- package/docs/icons.md +0 -79
- package/docs/index.md +0 -328
- package/docs/installer-options.md +0 -165
- package/docs/ipc.md +0 -61
- package/docs/lib-modules.md +0 -53
- package/docs/logging.md +0 -227
- package/docs/menu.md +0 -160
- package/docs/releasing.md +0 -239
- package/docs/remote-config.md +0 -118
- package/docs/remote-scripts.md +0 -144
- package/docs/restart-manager.md +0 -144
- package/docs/runner.md +0 -290
- package/docs/sentry.md +0 -97
- package/docs/shared/agent-docs.md +0 -89
- package/docs/shared/analytics.md +0 -612
- package/docs/shared/brands.md +0 -57
- package/docs/shared/breaking-changes.md +0 -917
- package/docs/shared/config.md +0 -1948
- package/docs/shared/deploys.md +0 -341
- package/docs/shared/icons.md +0 -219
- package/docs/shared/local-dev.md +0 -167
- package/docs/shared/logging.md +0 -205
- package/docs/shared/monitoring.md +0 -167
- package/docs/shared/publishing.md +0 -187
- package/docs/shared/rulings.md +0 -34
- package/docs/shared/testing.md +0 -147
- package/docs/shared/theming.md +0 -629
- package/docs/shared/translation.md +0 -342
- package/docs/shared/updates.md +0 -61
- package/docs/signing.md +0 -293
- package/docs/startup.md +0 -142
- package/docs/storage.md +0 -59
- package/docs/templating.md +0 -101
- package/docs/test-boot-layer.md +0 -157
- package/docs/test-framework.md +0 -362
- package/docs/themes.md +0 -149
- package/docs/tooltips.md +0 -99
- package/docs/tray.md +0 -164
- package/docs/usage.md +0 -58
- package/docs/verts.md +0 -62
- package/docs/windows.md +0 -149
|
@@ -5,17 +5,17 @@
|
|
|
5
5
|
* That is correct for a STANDALONE target (the target dir is the git root) and dead on
|
|
6
6
|
* arrival inside a brand monorepo: GitHub executes workflows from the REPO
|
|
7
7
|
* ROOT's `.github/workflows/` only, so `targets/extension/.github/workflows/publish.yml`
|
|
8
|
-
* never runs
|
|
8
|
+
* never runs: CI builds and store publishes silently do not exist.
|
|
9
9
|
*
|
|
10
10
|
* In a monorepo the target's workflow is COMPOSED into the root dir instead: one
|
|
11
11
|
* file per target (`<target>-<workflow>.yml`), every post-checkout `run:` step scoped
|
|
12
|
-
* to the target's path, regenerated from the framework template on every setup
|
|
12
|
+
* to the target's path, regenerated from the framework template on every setup, so
|
|
13
13
|
* a re-run updates the target's own file and can never duplicate a job. Each target's
|
|
14
14
|
* runs also get their own concurrency group, so one target's deploy never cancels
|
|
15
15
|
* another's.
|
|
16
16
|
*
|
|
17
17
|
* Scoping is by working directory, not by a `paths:` trigger filter: OMEGA
|
|
18
|
-
* workflows carry NO push triggers by design (deliberate deploys
|
|
18
|
+
* workflows carry NO push triggers by design (deliberate deploys, D13), and a
|
|
19
19
|
* path filter on a dispatch-only workflow filters nothing. It is declared PER
|
|
20
20
|
* STEP, not as a workflow-level `defaults.run.working-directory`: that would
|
|
21
21
|
* also scope the steps that run before actions/checkout (and the jobs that never
|
|
@@ -23,7 +23,7 @@
|
|
|
23
23
|
*
|
|
24
24
|
* Composition RECONCILES (#636): the enabled target set derives the composed
|
|
25
25
|
* file set, so a target the brand's config no longer enables has its composed
|
|
26
|
-
* files DELETED (reconcileComposedWorkflows)
|
|
26
|
+
* files DELETED (reconcileComposedWorkflows): the ratified example of a
|
|
27
27
|
* dropped config key becoming a real removal.
|
|
28
28
|
*/
|
|
29
29
|
const path = require('path');
|
|
@@ -68,7 +68,7 @@ const INSTALL_WORKSPACE_FLAG = '--workspace .';
|
|
|
68
68
|
* an unscoped artifact upload collects an empty repo-root path.
|
|
69
69
|
*
|
|
70
70
|
* Named per action on purpose: a blanket "any input called path" rule would
|
|
71
|
-
* rewrite inputs that mean something else
|
|
71
|
+
* rewrite inputs that mean something else: `actions/checkout`'s `path:` names
|
|
72
72
|
* where to CLONE the repo, not a file in it. An action missing from this table
|
|
73
73
|
* is left alone; add it here when a template starts using it.
|
|
74
74
|
*/
|
|
@@ -164,7 +164,7 @@ function composeWorkflow(contents, options) {
|
|
|
164
164
|
// (and its own engines.node pin) sits beside it.
|
|
165
165
|
composed = renderInstallWorkspace(composed, { composed: true });
|
|
166
166
|
|
|
167
|
-
// Display name carries the target
|
|
167
|
+
// Display name carries the target: two targets' runs are told apart in the
|
|
168
168
|
// Actions list, where only the workflow name shows.
|
|
169
169
|
composed = composed.replace(/^name:[ \t]*(.*)$/m, (full, value) => `name: ${value.trim()} (${targetPath})`);
|
|
170
170
|
|
|
@@ -173,7 +173,7 @@ function composeWorkflow(contents, options) {
|
|
|
173
173
|
composed = composed.replace(/^(concurrency:\n(?:[ \t]+.*\n)*?[ \t]+group:[ \t]*)(.*)$/m, (full, prefix, value) => `${prefix}${targetName}-${value.trim()}`);
|
|
174
174
|
|
|
175
175
|
// Every `run:` step that follows its job's checkout executes in the target dir
|
|
176
|
-
// (`uses:` actions
|
|
176
|
+
// (`uses:` actions, checkout and friends, stay at the repo root, which is
|
|
177
177
|
// what they want).
|
|
178
178
|
composed = scopeRunSteps(composed, targetPath);
|
|
179
179
|
|
|
@@ -238,12 +238,12 @@ function composeTargetWorkflows(options) {
|
|
|
238
238
|
/**
|
|
239
239
|
* Delete the composed workflows of targets this brand no longer has (#636).
|
|
240
240
|
* Composing is per-target and a dropped target's framework never runs again, so
|
|
241
|
-
* the file it wrote at the brand root would outlive it forever
|
|
241
|
+
* the file it wrote at the brand root would outlive it forever: a workflow
|
|
242
242
|
* Actions still lists and still dispatches for a target that is gone.
|
|
243
243
|
*
|
|
244
244
|
* The brand root's `.github/workflows/` is a MIXED dir: the brand's own
|
|
245
245
|
* human-authored workflows live beside the composed ones. So a file goes only
|
|
246
|
-
* past a DOUBLE lock
|
|
246
|
+
* past a DOUBLE lock: it carries the GENERATED header (which also NAMES the
|
|
247
247
|
* target it was composed for) AND it is named the composed way,
|
|
248
248
|
* `<target>-<framework file>.yml`. A human file matches neither and is never
|
|
249
249
|
* even a candidate; nothing here reads or edits one.
|
|
@@ -283,14 +283,14 @@ function reconcileComposedWorkflows(options) {
|
|
|
283
283
|
jetpack.remove(file);
|
|
284
284
|
}
|
|
285
285
|
result.removed.push(`.github/workflows/${name}`);
|
|
286
|
-
logger.log(`Removed .github/workflows/${name}
|
|
286
|
+
logger.log(`Removed .github/workflows/${name}: ${owned.targetPath} is no longer a target of this brand`);
|
|
287
287
|
}
|
|
288
288
|
|
|
289
289
|
return result;
|
|
290
290
|
}
|
|
291
291
|
|
|
292
292
|
/**
|
|
293
|
-
* The workflow file name to dispatch for a target
|
|
293
|
+
* The workflow file name to dispatch for a target: composed in a brand monorepo,
|
|
294
294
|
* the framework's own name standalone. The ONE place deploy verbs and the
|
|
295
295
|
* compose step agree on the name.
|
|
296
296
|
* @param {object} options
|
|
@@ -324,7 +324,7 @@ function composedWorkflowNameFor(name, workflow) {
|
|
|
324
324
|
// A workflow-level `defaults.run.working-directory` reads cleaner but applies to
|
|
325
325
|
// EVERY run step, including the ones that execute before actions/checkout (the
|
|
326
326
|
// git config step, a matrix-resolving step) and the jobs that never check out at
|
|
327
|
-
// all
|
|
327
|
+
// all: the target dir does not exist there yet, and the job dies on step 1.
|
|
328
328
|
function scopeRunSteps(contents, targetPath) {
|
|
329
329
|
const lines = contents.split('\n');
|
|
330
330
|
const output = [];
|
|
@@ -343,7 +343,7 @@ function scopeRunSteps(contents, targetPath) {
|
|
|
343
343
|
|
|
344
344
|
for (const line of lines) {
|
|
345
345
|
const indent = line.search(/\S/);
|
|
346
|
-
// Comments and blank lines never close a block
|
|
346
|
+
// Comments and blank lines never close a block; they belong to what follows
|
|
347
347
|
const structural = indent >= 0 && !line.trimStart().startsWith('#');
|
|
348
348
|
|
|
349
349
|
// A top-level key closes any open steps list and says whether we are in `jobs:`
|
|
@@ -417,8 +417,8 @@ function actionOf(step, keyIndent) {
|
|
|
417
417
|
return line ? line.slice(line.indexOf('uses:') + 5).trim().split('@')[0] : null;
|
|
418
418
|
}
|
|
419
419
|
|
|
420
|
-
// `hashFiles()` globs from GITHUB_WORKSPACE wherever it appears
|
|
421
|
-
// moves it
|
|
420
|
+
// `hashFiles()` globs from GITHUB_WORKSPACE wherever it appears (no step key
|
|
421
|
+
// moves it), so a composed target workflow's patterns carry the target path or match
|
|
422
422
|
// nothing at all.
|
|
423
423
|
function scopeHashFiles(step, targetPath) {
|
|
424
424
|
return step.map((line) => line.replace(/hashFiles\(([^)]*)\)/g, (full, args) => {
|
|
@@ -491,7 +491,7 @@ function scopeValue(value, targetPath) {
|
|
|
491
491
|
}
|
|
492
492
|
|
|
493
493
|
// A target-relative path/glob. Absolute paths and expression-built values name
|
|
494
|
-
// something other than a file in the checkout
|
|
494
|
+
// something other than a file in the checkout; those are left as written.
|
|
495
495
|
function joinTargetPath(value, targetPath) {
|
|
496
496
|
const trimmed = value.trim();
|
|
497
497
|
const scoped = trimmed.replace(/^\.\//, '');
|
|
@@ -503,7 +503,7 @@ function joinTargetPath(value, targetPath) {
|
|
|
503
503
|
return `${targetPath}/${scoped}`;
|
|
504
504
|
}
|
|
505
505
|
|
|
506
|
-
// Declare `working-directory` on one run step's lines
|
|
506
|
+
// Declare `working-directory` on one run step's lines. A step that already
|
|
507
507
|
// declares its own is left alone.
|
|
508
508
|
function scopeRunStep(step, keyIndent, targetPath) {
|
|
509
509
|
const pad = ' '.repeat(keyIndent);
|
|
@@ -544,11 +544,11 @@ function sweepTargetCopy(context) {
|
|
|
544
544
|
jetpack.remove(targetCopy);
|
|
545
545
|
pruneEmptyDirs(path.dirname(targetCopy), targetDir);
|
|
546
546
|
result.removed.push(`.github/workflows/${name}`);
|
|
547
|
-
logger.log(`Removed ${targetPath}/.github/workflows/${name}
|
|
547
|
+
logger.log(`Removed ${targetPath}/.github/workflows/${name}: GitHub only runs workflows from the repo root`);
|
|
548
548
|
return;
|
|
549
549
|
}
|
|
550
550
|
|
|
551
|
-
logger.warn(`Kept ${targetPath}/.github/workflows/${name}
|
|
551
|
+
logger.warn(`Kept ${targetPath}/.github/workflows/${name}: it differs from the current framework template (your edits, or an older framework version), and GitHub NEVER runs a workflow from a target dir. Compare it against ${composedName}, move anything it still needs, then delete ${targetPath}/.github/workflows/${name}`);
|
|
552
552
|
}
|
|
553
553
|
|
|
554
554
|
// Is every line of the target's copy a line the current template still ships, in
|
|
@@ -559,7 +559,7 @@ function sweepTargetCopy(context) {
|
|
|
559
559
|
// same file minus those lines, #334). A line the consumer ADDED or CHANGED is a
|
|
560
560
|
// line no template of this framework ever shipped, so it fails here and the
|
|
561
561
|
// copy is kept. Accepted blind spot: an edit that ONLY deletes lines is still a
|
|
562
|
-
// subsequence and gets swept
|
|
562
|
+
// subsequence and gets swept. Bounded, because a target-dir workflow never runs
|
|
563
563
|
// and the composed root file is regenerated from the current template.
|
|
564
564
|
function isFrameworkGeneration(existing, rendered) {
|
|
565
565
|
const template = rendered.split('\n');
|
|
@@ -587,7 +587,7 @@ function pruneEmptyDirs(dir, rootDir) {
|
|
|
587
587
|
}
|
|
588
588
|
}
|
|
589
589
|
|
|
590
|
-
// posix-style target path
|
|
590
|
+
// posix-style target path: this string ends up inside a YAML workflow.
|
|
591
591
|
function relativePath(from, to) {
|
|
592
592
|
return path.relative(from, to).split(path.sep).join('/');
|
|
593
593
|
}
|
|
@@ -596,14 +596,14 @@ function relativePath(from, to) {
|
|
|
596
596
|
// the file is framework-owned AND which target it belongs to, which is what
|
|
597
597
|
// makes the reconcile able to tell a composed file from a brand's own workflow
|
|
598
598
|
// without guessing from the name alone.
|
|
599
|
-
const generatedMark = (targetPath) => `# GENERATED by the ${targetPath} framework scaffold
|
|
600
|
-
// Reading accepts the
|
|
601
|
-
//
|
|
602
|
-
//
|
|
603
|
-
//
|
|
604
|
-
const GENERATED_MARK = /^# GENERATED by (?:the (\S+) framework scaffold|`omega setup` for (\S+))
|
|
605
|
-
|
|
606
|
-
// Which target a brand-root workflow file was composed for
|
|
599
|
+
const generatedMark = (targetPath) => `# GENERATED by the ${targetPath} framework scaffold. Do not edit.`;
|
|
600
|
+
// Reading also accepts the older forms (the `omega setup` wording, the em dash
|
|
601
|
+
// separator): a dropped target keeps its old-header file forever, which is exactly
|
|
602
|
+
// the file the reconcile exists to remove. Composing only ever writes the current
|
|
603
|
+
// sentence.
|
|
604
|
+
const GENERATED_MARK = /^# GENERATED by (?:the (\S+) framework scaffold|`omega setup` for (\S+))(?:\. Do| \u2014 do) not edit\.$/;
|
|
605
|
+
|
|
606
|
+
// Which target a brand-root workflow file was composed for, null when the file
|
|
607
607
|
// is not a composed one. BOTH locks must hold: the generation mark on line 1,
|
|
608
608
|
// and the `<target>-<framework file>.yml` naming the compose writes.
|
|
609
609
|
function composedWorkflowOwner(name, contents) {
|
|
@@ -633,4 +633,4 @@ function header(targetPath) {
|
|
|
633
633
|
].join('\n');
|
|
634
634
|
}
|
|
635
635
|
|
|
636
|
-
module.exports = { composeWorkflow, composeTargetWorkflows, composedWorkflowName, composedWorkflowNameFor, reconcileComposedWorkflows, renderInstallFirewall, renderInstallWorkspace, FIREWALL_ACTION, FIREWALL_STEP_ID, FIREWALL_TOKEN, INSTALL_WORKSPACE_TOKEN, INSTALL_WORKSPACE_FLAG };
|
|
636
|
+
module.exports = { composeWorkflow, composeTargetWorkflows, composedWorkflowName, composedWorkflowNameFor, composedWorkflowOwner, reconcileComposedWorkflows, renderInstallFirewall, renderInstallWorkspace, FIREWALL_ACTION, FIREWALL_STEP_ID, FIREWALL_TOKEN, INSTALL_WORKSPACE_TOKEN, INSTALL_WORKSPACE_FLAG };
|
|
@@ -27,6 +27,7 @@ const jetpack = require('fs-jetpack');
|
|
|
27
27
|
* @param {string} config.commandsDir - Absolute directory of command modules — <name>.js exporting `async (options) => {}`
|
|
28
28
|
* @param {Object<string, string[]>} [config.aliases] - Command name → positional/flag aliases (e.g. `{ install: ['-i', 'i', '--install'] }`)
|
|
29
29
|
* @param {string} [config.defaultCommand='help'] - Command used when no positional or flag alias matches
|
|
30
|
+
* @param {Function} [config.fallback] - `(command) => handler|null`: the handler for a command with no file, else null for the unknown answer
|
|
30
31
|
* @returns {Function} Main class — bins do `new Main(argv)` then `await main.process(argv)`
|
|
31
32
|
*/
|
|
32
33
|
function createCliRouter(config) {
|
|
@@ -35,6 +36,7 @@ function createCliRouter(config) {
|
|
|
35
36
|
const commandsDir = config.commandsDir;
|
|
36
37
|
const aliases = config.aliases || {};
|
|
37
38
|
const defaultCommand = config.defaultCommand || 'help';
|
|
39
|
+
const fallback = config.fallback || null;
|
|
38
40
|
|
|
39
41
|
if (!commandsDir) {
|
|
40
42
|
throw new Error('[devkit cli-router] commandsDir is required');
|
|
@@ -114,14 +116,18 @@ function createCliRouter(config) {
|
|
|
114
116
|
|
|
115
117
|
// Get the command file path
|
|
116
118
|
const commandFile = path.join(commandsDir, `${command}.js`);
|
|
119
|
+
const exists = jetpack.exists(commandFile);
|
|
117
120
|
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
121
|
+
// Built-in help (a framework's own commands/help.js would have won above)
|
|
122
|
+
if (!exists && command === 'help') {
|
|
123
|
+
printHelp();
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// A router can own verbs it keeps no file for; a null handler is the unknown answer
|
|
128
|
+
const handler = !exists && fallback ? fallback(command) : null;
|
|
124
129
|
|
|
130
|
+
if (!exists && !handler) {
|
|
125
131
|
// Unknown command: name the valid surface and fail loud — the old path
|
|
126
132
|
// threw a doubled "Error executing… Error: Command…" with no listing.
|
|
127
133
|
// An empty listing means the dist itself is broken, not a typo — say so.
|
|
@@ -137,7 +143,7 @@ function createCliRouter(config) {
|
|
|
137
143
|
|
|
138
144
|
try {
|
|
139
145
|
// Execute the command
|
|
140
|
-
const Command = require(commandFile);
|
|
146
|
+
const Command = handler || require(commandFile);
|
|
141
147
|
await Command(options);
|
|
142
148
|
} catch (e) {
|
|
143
149
|
// The command's own error is the only surface — no wrapper prefix, no
|
|
@@ -1,40 +1,8 @@
|
|
|
1
|
-
// applyDefaults(config)
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
// File-map rules: minimatch patterns (dot:true) matched against the RAW path
|
|
8
|
-
// relative to defaultsDir, last-match-wins option merging. Per-rule options:
|
|
9
|
-
// overwrite bool|fn(item) default true — write even if the destination exists
|
|
10
|
-
// skip bool|fn(item) default false — never process this file
|
|
11
|
-
// name fn(item) rename the output file
|
|
12
|
-
// path fn(item) re-destination the output (relative dir)
|
|
13
|
-
// template object render `{{ key.path }}` tokens with this data
|
|
14
|
-
// (tolerant: unknown keys survive verbatim, so GitHub
|
|
15
|
-
// Actions' `${{ secrets.X }}` passes through)
|
|
16
|
-
// merge bool JSON5 defaults-merge with the existing file, written
|
|
17
|
-
// through @omega.js/config's comment-preserving editor
|
|
18
|
-
// (only the differing keys change; comments stay)
|
|
19
|
-
// mergeLines bool OMEGA marker-section line merge (.env/.gitignore/AGENTS.md)
|
|
20
|
-
// retire bool brand-context per-target doc retirement: NEVER scaffold
|
|
21
|
-
// the file; an existing framework-owned-only copy is
|
|
22
|
-
// DELETED (one-time heal — the brand root is the doc
|
|
23
|
-
// home), a copy carrying consumer content stays with a
|
|
24
|
-
// loud move-it warning (never destroyed)
|
|
25
|
-
//
|
|
26
|
-
// Engine built-ins (not expressed in the file map):
|
|
27
|
-
// - `_.name` segments lose the leading `_` (dotfiles ship past npm's filter)
|
|
28
|
-
// - non-final segments starting `_` (but not `_.`) are ARCHIVE dirs — skipped
|
|
29
|
-
// (reference material that ships in the framework package, e.g. desktop's `_mas/`)
|
|
30
|
-
// - `.gitkeep` creates the destination directory, the file itself never copies
|
|
31
|
-
// - `.DS_Store` never copies
|
|
32
|
-
// - text writes are skipped when the destination is byte-identical (idempotent
|
|
33
|
-
// re-runs report zero writes)
|
|
34
|
-
// - a destination carrying the OMEGA section markers when the current template
|
|
35
|
-
// no longer does is a PRIOR-GENERATION generated file: framework-owned, so
|
|
36
|
-
// `retire` sweeps it and `overwrite: false` still heals it
|
|
37
|
-
// - binary files (by extension) copy verbatim — never templated/merged/transformed
|
|
1
|
+
// applyDefaults(config): the ONE plain-fs defaults-scaffolding engine every
|
|
2
|
+
// framework's scaffold runs, copying its defaults tree into the consumer
|
|
3
|
+
// project. The file-map options (overwrite, skip, name, path, template, merge,
|
|
4
|
+
// mergeLines, retire), the built-ins and the minimatch matching rules live in
|
|
5
|
+
// docs/devkit/index.md.
|
|
38
6
|
|
|
39
7
|
const path = require('path');
|
|
40
8
|
const fs = require('fs');
|
|
@@ -264,12 +232,10 @@ function isFrameworkOwned(existing, rendered) {
|
|
|
264
232
|
return existing.trim() === rendered.trim();
|
|
265
233
|
}
|
|
266
234
|
|
|
267
|
-
// A generation change: the destination carries the OMEGA marker grammar
|
|
268
|
-
//
|
|
269
|
-
//
|
|
270
|
-
//
|
|
271
|
-
// pointer), so comparing it against the current render can never match and the
|
|
272
|
-
// framework, not the consumer, owns it.
|
|
235
|
+
// A generation change: the destination carries the OMEGA marker grammar (only
|
|
236
|
+
// this framework's scaffold writes it) while the current template dropped it.
|
|
237
|
+
// That file is a PRIOR GENERATION's copy: it can never match the current
|
|
238
|
+
// render, and the framework, not the consumer, owns it.
|
|
273
239
|
function isLegacyMarkerArtifact(existing, rendered) {
|
|
274
240
|
return hasSectionMarkers(existing) && !hasSectionMarkers(rendered);
|
|
275
241
|
}
|
|
@@ -59,6 +59,7 @@ const { setTimeout: delay } = require('node:timers/promises');
|
|
|
59
59
|
const jetpack = require('fs-jetpack');
|
|
60
60
|
const { resolveCompany, COMPANY_RESOLVED_FILE } = require('../config/index.js');
|
|
61
61
|
const { gitAuthEnv, scrubToken } = require('./git-auth.js');
|
|
62
|
+
const { composedWorkflowOwner } = require('./ci-workflows.js');
|
|
62
63
|
|
|
63
64
|
// Constants
|
|
64
65
|
const API_BASE = 'https://api.github.com';
|
|
@@ -342,7 +343,9 @@ async function defaultBranchOf({ owner, repo, token, fetchFn = fetch }) {
|
|
|
342
343
|
* this write exists, and it is the only write a deploy ever makes to a default
|
|
343
344
|
* branch (Ian, 2026-09-14: "if all we are pushing to main is the gh workflow
|
|
344
345
|
* files that's fine"). Each file's bytes are compared with what the branch
|
|
345
|
-
* holds; when every one matches, nothing is written at all.
|
|
346
|
+
* holds; when every one matches, nothing is written at all. A branch file
|
|
347
|
+
* that carries the GENERATED header and is no longer composed (a renamed or
|
|
348
|
+
* dropped target) is RETIRED in the same commit; a hand-written one never is.
|
|
346
349
|
*
|
|
347
350
|
* Through the git DATA api, never a working-tree commit: blobs, a tree on top
|
|
348
351
|
* of the branch's own, a commit whose parent is its head. An EMPTY repo (no
|
|
@@ -356,14 +359,14 @@ async function defaultBranchOf({ owner, repo, token, fetchFn = fetch }) {
|
|
|
356
359
|
* @param {string} options.token - The GitHub token.
|
|
357
360
|
* @param {Function} [options.fetchFn] - Injectable fetch (tests).
|
|
358
361
|
* @param {object} [options.logger] - Logger with `log` (silent when omitted).
|
|
359
|
-
* @returns {Promise<{ pushed: string[], sha: string|null }>} What was written.
|
|
362
|
+
* @returns {Promise<{ pushed: string[], removed: string[], sha: string|null }>} What was written.
|
|
360
363
|
*/
|
|
361
364
|
async function pushWorkflowFiles({ brandRoot, owner, repo, branch, token, fetchFn = fetch, logger }) {
|
|
362
365
|
const files = composedWorkflowFiles(brandRoot);
|
|
363
366
|
|
|
364
367
|
if (files.length === 0) {
|
|
365
368
|
if (logger) logger.log(`No composed workflow in ${brandRoot}/${WORKFLOWS_DIR}: ${branch} is untouched.`);
|
|
366
|
-
return { pushed: [], sha: null };
|
|
369
|
+
return { pushed: [], removed: [], sha: null };
|
|
367
370
|
}
|
|
368
371
|
|
|
369
372
|
const call = async (route, init) => {
|
|
@@ -399,9 +402,31 @@ async function pushWorkflowFiles({ brandRoot, owner, repo, branch, token, fetchF
|
|
|
399
402
|
}
|
|
400
403
|
}
|
|
401
404
|
|
|
402
|
-
|
|
405
|
+
// What the branch holds that this brand no longer composes. The same double
|
|
406
|
+
// lock as the local prune: the GENERATED header and the composed naming, so
|
|
407
|
+
// a hand-written workflow is never a candidate. No listing means no files.
|
|
408
|
+
const composed = new Set(files.map((file) => file.name));
|
|
409
|
+
const listing = await read(
|
|
410
|
+
await call(`/contents/${WORKFLOWS_DIR}?ref=${encodeURIComponent(branch)}`),
|
|
411
|
+
[200, 404],
|
|
412
|
+
`Listing ${WORKFLOWS_DIR}`,
|
|
413
|
+
);
|
|
414
|
+
const retired = [];
|
|
415
|
+
|
|
416
|
+
for (const entry of listing || []) {
|
|
417
|
+
if (entry.type !== 'file' || !entry.name.endsWith('.yml') || composed.has(entry.name)) {
|
|
418
|
+
continue;
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
const held = await read(await call(`/contents/${entry.path}?ref=${encodeURIComponent(branch)}`), [200], `Reading ${entry.name}`);
|
|
422
|
+
if (composedWorkflowOwner(entry.name, Buffer.from(held.content || '', 'base64').toString('utf8'))) {
|
|
423
|
+
retired.push({ name: entry.name, path: entry.path });
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
if (changed.length === 0 && retired.length === 0) {
|
|
403
428
|
if (logger) logger.log(`${owner}/${repo}#${branch} already carries ${files.map((file) => file.name).join(', ')}: nothing pushed to ${branch}.`);
|
|
404
|
-
return { pushed: [], sha: null };
|
|
429
|
+
return { pushed: [], removed: [], sha: null };
|
|
405
430
|
}
|
|
406
431
|
|
|
407
432
|
// The head this commit sits on. A repo with no commit at all answers 404, and
|
|
@@ -422,15 +447,21 @@ async function pushWorkflowFiles({ brandRoot, owner, repo, branch, token, fetchF
|
|
|
422
447
|
);
|
|
423
448
|
tree.push({ path: file.path, mode: '100644', type: 'blob', sha: blob.sha });
|
|
424
449
|
}
|
|
450
|
+
for (const file of retired) {
|
|
451
|
+
tree.push({ path: file.path, mode: '100644', type: 'blob', sha: null });
|
|
452
|
+
}
|
|
425
453
|
|
|
426
|
-
const names =
|
|
454
|
+
const names = [
|
|
455
|
+
...(changed.length ? [`compose ${changed.map((file) => file.name).join(', ')}`] : []),
|
|
456
|
+
...(retired.length ? [`retire ${retired.map((file) => file.name).join(', ')}`] : []),
|
|
457
|
+
].join(', ');
|
|
427
458
|
const written = await read(
|
|
428
459
|
await call('/git/trees', { method: 'POST', body: JSON.stringify({ ...(baseTree ? { base_tree: baseTree } : {}), tree }) }),
|
|
429
460
|
[201],
|
|
430
461
|
'Writing the tree',
|
|
431
462
|
);
|
|
432
463
|
const commit = await read(
|
|
433
|
-
await call('/git/commits', { method: 'POST', body: JSON.stringify({ message: `chore(ci):
|
|
464
|
+
await call('/git/commits', { method: 'POST', body: JSON.stringify({ message: `chore(ci): ${names}`, tree: written.sha, parents }) }),
|
|
434
465
|
[201],
|
|
435
466
|
'Writing the commit',
|
|
436
467
|
);
|
|
@@ -444,10 +475,14 @@ async function pushWorkflowFiles({ brandRoot, owner, repo, branch, token, fetchF
|
|
|
444
475
|
);
|
|
445
476
|
|
|
446
477
|
if (logger) {
|
|
447
|
-
|
|
478
|
+
const touched = [
|
|
479
|
+
...changed.map((file) => `${file.name} (${file.reason})`),
|
|
480
|
+
...retired.map((file) => `${file.name} (retired)`),
|
|
481
|
+
];
|
|
482
|
+
logger.log(`Pushed ${touched.join(', ')} to ${owner}/${repo}#${branch} as ${shortSha(commit.sha)}: GitHub registers a workflow from the default branch.`);
|
|
448
483
|
}
|
|
449
484
|
|
|
450
|
-
return { pushed: changed.map((file) => file.name), sha: commit.sha };
|
|
485
|
+
return { pushed: changed.map((file) => file.name), removed: retired.map((file) => file.name), sha: commit.sha };
|
|
451
486
|
}
|
|
452
487
|
|
|
453
488
|
/**
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The .env line grammar the marker engine merges by and every in-place key
|
|
3
|
+
* writer sets keys by: logical lines (a quoted value spanning several physical
|
|
4
|
+
* lines is one unit), the key a line assigns, the double-quote normalization,
|
|
5
|
+
* and the replace-or-append of a key's lines. dotenv is the reader every OMEGA loader
|
|
6
|
+
* uses, so it is the judge: a normalized line must read back to the same value,
|
|
7
|
+
* and a key's winning line is its LAST one, as dotenv reads it.
|
|
8
|
+
*/
|
|
9
|
+
const dotenv = require('dotenv');
|
|
10
|
+
const { envLine } = require('../config/index.js');
|
|
11
|
+
|
|
12
|
+
// An assignment dotenv accepts: optional indent and `export `, then KEY=
|
|
13
|
+
const ASSIGNMENT = /^(\s*(?:export\s+)?)([\w.-]+)\s*=(.*)$/s;
|
|
14
|
+
const QUOTED_OPEN = /^\s*(?:export\s+)?[\w.-]+\s*=\s*(["'`])/;
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* The index of the quote closing a value opened at `from`, as dotenv picks it:
|
|
18
|
+
* a quote after a backslash may close the value or be passed, the first quote
|
|
19
|
+
* after no backslash cannot be passed, and dotenv takes the LAST closing
|
|
20
|
+
* candidate followed on its line by only whitespace or a `# comment`, so
|
|
21
|
+
* `K="x\"` reads `x\`. -1 when none closes it.
|
|
22
|
+
*
|
|
23
|
+
* @param {string} text
|
|
24
|
+
* @param {string} quote - `"`, `'` or a backtick
|
|
25
|
+
* @param {number} from - First index after the opening quote
|
|
26
|
+
* @returns {number}
|
|
27
|
+
*/
|
|
28
|
+
function closingQuote(text, quote, from) {
|
|
29
|
+
const lineRest = /[^\S\n]*(?:#[^\n]*)?(?:\n|$)/y;
|
|
30
|
+
let close = -1;
|
|
31
|
+
for (let i = from; i < text.length; i += 1) {
|
|
32
|
+
if (text[i] !== quote) continue;
|
|
33
|
+
lineRest.lastIndex = i + 1;
|
|
34
|
+
if (lineRest.test(text)) close = i;
|
|
35
|
+
if (text[i - 1] !== '\\') break;
|
|
36
|
+
}
|
|
37
|
+
return close;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Group physical lines into logical ones: a quoted value that closes on a later
|
|
42
|
+
* line joins those lines (newlines kept) into one unit, so it moves and
|
|
43
|
+
* survives whole. An unterminated quote stays line by line, as dotenv reads it.
|
|
44
|
+
*
|
|
45
|
+
* @param {string[]} lines - The file split on `\n`
|
|
46
|
+
* @returns {string[]}
|
|
47
|
+
*/
|
|
48
|
+
function joinEnvUnits(lines) {
|
|
49
|
+
const units = [];
|
|
50
|
+
for (let i = 0; i < lines.length; i += 1) {
|
|
51
|
+
const open = lines[i].match(QUOTED_OPEN);
|
|
52
|
+
const rest = open ? lines.slice(i).join('\n') : '';
|
|
53
|
+
const close = open ? closingQuote(rest, open[1], open[0].length) : -1;
|
|
54
|
+
if (close < 0) {
|
|
55
|
+
units.push(lines[i]);
|
|
56
|
+
continue;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const end = i + rest.slice(0, close).split('\n').length - 1;
|
|
60
|
+
units.push(lines.slice(i, end + 1).join('\n'));
|
|
61
|
+
i = end;
|
|
62
|
+
}
|
|
63
|
+
return units;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* The key a line assigns, or null for a comment, a blank, or anything else.
|
|
68
|
+
*
|
|
69
|
+
* @param {string} line
|
|
70
|
+
* @returns {string|null}
|
|
71
|
+
*/
|
|
72
|
+
function envKey(line) {
|
|
73
|
+
const match = line.match(ASSIGNMENT);
|
|
74
|
+
return match ? match[2] : null;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// The inline comment after a raw value: past the closing quote of a quoted
|
|
78
|
+
// value, from the first `#` of an unquoted one.
|
|
79
|
+
function trailingComment(raw) {
|
|
80
|
+
let rest = raw;
|
|
81
|
+
if (raw[0] === "'" || raw[0] === '`') {
|
|
82
|
+
const close = closingQuote(raw, raw[0], 1);
|
|
83
|
+
if (close >= 0) rest = raw.slice(close + 1);
|
|
84
|
+
}
|
|
85
|
+
const hash = rest.indexOf('#');
|
|
86
|
+
return hash < 0 ? '' : rest.slice(hash).trim();
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Normalize one .env line to the double-quoted form: `KEY=raw` and `KEY='x'`
|
|
91
|
+
* become `KEY="..."`, an inline comment kept; an empty value becomes `KEY=""`;
|
|
92
|
+
* a double-quoted value, a comment and a blank are left alone. The rewrite
|
|
93
|
+
* happens only when dotenv reads the same value back; otherwise the line stays.
|
|
94
|
+
*
|
|
95
|
+
* @param {string} line - One logical line
|
|
96
|
+
* @returns {string}
|
|
97
|
+
*/
|
|
98
|
+
function normalizeEnvLine(line) {
|
|
99
|
+
const match = line.match(ASSIGNMENT);
|
|
100
|
+
if (!match) return line;
|
|
101
|
+
const [, prefix, key, rawValue] = match;
|
|
102
|
+
const raw = rawValue.trim();
|
|
103
|
+
|
|
104
|
+
if (raw === '') return `${prefix}${key}=""`;
|
|
105
|
+
if (raw.startsWith('"')) return line;
|
|
106
|
+
|
|
107
|
+
const value = dotenv.parse(line)[key];
|
|
108
|
+
if (value === undefined) return line;
|
|
109
|
+
|
|
110
|
+
let quoted;
|
|
111
|
+
try {
|
|
112
|
+
quoted = envLine(key, value);
|
|
113
|
+
} catch (e) {
|
|
114
|
+
// envLine refuses a value dotenv would read back changed: the line stays
|
|
115
|
+
return line;
|
|
116
|
+
}
|
|
117
|
+
const comment = trailingComment(raw);
|
|
118
|
+
const candidate = `${prefix}${quoted}${comment ? ` ${comment}` : ''}`;
|
|
119
|
+
return dotenv.parse(candidate)[key] === value ? candidate : line;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* The line that sets `key`: the LAST one, the value dotenv reads.
|
|
124
|
+
*
|
|
125
|
+
* @param {string[]} lines
|
|
126
|
+
* @param {string} key
|
|
127
|
+
* @returns {string} - `KEY=""` when no line sets it
|
|
128
|
+
*/
|
|
129
|
+
function findKeyLine(lines, key) {
|
|
130
|
+
for (let i = lines.length - 1; i >= 0; i -= 1) {
|
|
131
|
+
if (envKey(lines[i]) === key) return lines[i];
|
|
132
|
+
}
|
|
133
|
+
return `${key}=""`;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// A placeholder, `# KEY=""` or a bare `# KEY=`, → KEY; any other comment → null.
|
|
137
|
+
function parsePlaceholderKey(trimmed) {
|
|
138
|
+
const match = trimmed.match(/^#\s*([A-Za-z_][A-Za-z0-9_]*)=\s*(?:"")?\s*$/);
|
|
139
|
+
return match ? match[1] : null;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Set keys in .env content by logical line: the first unit assigning a key, or
|
|
144
|
+
* its `# KEY=""` placeholder, becomes the given line and every later one drops,
|
|
145
|
+
* so dotenv reads the new value; a key the content never had is appended.
|
|
146
|
+
* Every other line keeps its place.
|
|
147
|
+
*
|
|
148
|
+
* @param {string} content - Current file content ('' for none)
|
|
149
|
+
* @param {Object<string, string>} lineFor - KEY to its whole line, e.g. envLine's
|
|
150
|
+
* @returns {string} The new content, ending in exactly one newline
|
|
151
|
+
*/
|
|
152
|
+
function setEnvLines(content, lineFor) {
|
|
153
|
+
const body = content.replace(/(?:\r?\n)+$/, '');
|
|
154
|
+
const seen = new Set();
|
|
155
|
+
|
|
156
|
+
const out = joinEnvUnits(body === '' ? [] : body.split(/\r?\n/)).flatMap((unit) => {
|
|
157
|
+
const key = envKey(unit) ?? parsePlaceholderKey(unit.trim());
|
|
158
|
+
if (key === null || !Object.hasOwn(lineFor, key)) return [unit];
|
|
159
|
+
if (seen.has(key)) return [];
|
|
160
|
+
seen.add(key);
|
|
161
|
+
return [lineFor[key]];
|
|
162
|
+
});
|
|
163
|
+
for (const key of Object.keys(lineFor)) {
|
|
164
|
+
if (!seen.has(key)) out.push(lineFor[key]);
|
|
165
|
+
}
|
|
166
|
+
return `${out.join('\n')}\n`;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// The framework's own .env comment grammar: a boxed group header or a
|
|
170
|
+
// `# KEY=""` placeholder. Regenerated from the template, never a consumer's note.
|
|
171
|
+
function isMachineComment(trimmed) {
|
|
172
|
+
return /^# ── .* ──$/.test(trimmed) || parsePlaceholderKey(trimmed) !== null;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// KEY= / KEY="" / KEY='' (whitespace tolerated) count as empty.
|
|
176
|
+
function envValueIsEmpty(line) {
|
|
177
|
+
const eqIdx = line.indexOf('=');
|
|
178
|
+
if (eqIdx < 0) return true;
|
|
179
|
+
const value = line.slice(eqIdx + 1).trim();
|
|
180
|
+
return value === '' || value === '""' || value === "''";
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
module.exports = { joinEnvUnits, envKey, normalizeEnvLine, findKeyLine, parsePlaceholderKey, isMachineComment, envValueIsEmpty, setEnvLines };
|